Add Telegram bot integration for pipeline processing (#5185)

# Description of Changes

This pull request introduces Telegram bot integration to the
application, enabling users to send files via Telegram for processing
through the pipeline. The main changes add configuration options,
dependency management, and a new service for handling Telegram
interactions.

**Telegram bot integration:**

* Added a new `TelegramPipelineBot` service (`TelegramPipelineBot.java`)
that listens for incoming Telegram messages, downloads attached files or
photos, places them in a pipeline inbox folder, waits for processing
results, and sends the output files back to the user. The service
includes error handling and status messaging.
* Introduced a `TelegramBotConfig` configuration class to initialize and
register the Telegram bot only when enabled via application properties.
* Added a new `Telegram` configuration section to
`ApplicationProperties` and the `settings.yml.template`, supporting
options like enabling/disabling the bot, bot token/username, pipeline
folder, processing timeout, and polling interval.
[[1]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R63)
[[2]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R580-R589)
[[3]](diffhunk://#diff-12f23603ae35319a3ea08f91b6340d5d935216941fda2e69d2df1b6cd22a63f2R108-R115)

**Dependency management:**

* Added the `org.telegram:telegrambots` library to the project
dependencies to support Telegram bot functionality.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Ludy
2026-01-13 22:14:59 +00:00
committed by GitHub
co-authored by Copilot Anthony Stirling
parent 83e96a9aa3
commit e7b030e6b5
13 changed files with 1313 additions and 84 deletions
@@ -61,6 +61,7 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Telegram telegram = new Telegram();
private Premium premium = new Premium();
@@ -551,10 +552,10 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
Driver {
driverName='%s'
}
"""
Driver {
driverName='%s'
}
"""
.formatted(driverName);
}
}
@@ -607,6 +608,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private CustomMetadata customMetadata = new CustomMetadata();
@Deprecated
@Data
public static class CustomMetadata {
private boolean autoUpdateMetadata;
@@ -614,16 +616,23 @@ public class ApplicationProperties {
private String creator;
private String producer;
@Deprecated
public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
}
@Deprecated
public String getProducer() {
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
}
}
}
/**
* Mail server configuration properties.
*
* @since 0.46.1
*/
@Data
public static class Mail {
private boolean enabled;
@@ -646,6 +655,102 @@ public class ApplicationProperties {
private Boolean sslCheckServerIdentity;
}
/**
* Telegram bot configuration properties.
*
* @since 2.2.x
*/
@Data
public static class Telegram {
private Boolean enabled = false;
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
private Feedback feedback = new Feedback();
/**
* Configuration for feedback messages sent by the Telegram bot.
*
* @since 2.2.x
*/
@Data
public static class Feedback {
private Channel channel = new Channel();
private User user = new User();
/**
* Channel-specific feedback settings.
*
* @since 2.2.x
*/
@Data
public static class Channel {
/**
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
* the channel (to avoid spam).
*/
private Boolean noValidDocument = true;
/**
* Set to {@code false} to hide/suppress generic error feedback messages to the
* channel (to avoid spam).
*/
private Boolean errorMessage = true;
/**
* Set to {@code false} to hide/suppress processing error feedback messages to the
* channel (to avoid spam).
*/
private Boolean errorProcessing = true;
/**
* Set to {@code false} to hide/suppress "processing" feedback messages to the
* channel (to avoid spam).
*/
private Boolean processing = true;
}
/**
* User-specific feedback settings.
*
* @since 2.2.x
*/
@Data
public static class User {
/**
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
* users (to avoid spam).
*/
private Boolean noValidDocument = true;
/**
* Set to {@code false} to hide/suppress generic error feedback messages to users
* (to avoid spam).
*/
private Boolean errorMessage = true;
/**
* Set to {@code false} to hide/suppress processing error feedback messages to users
* (to avoid spam).
*/
private Boolean errorProcessing = true;
/**
* Set to {@code false} to hide/suppress "processing" feedback messages to users (to
* avoid spam).
*/
private Boolean processing = true;
}
}
}
@Data
public static class Premium {
private boolean enabled;
+1
View File
@@ -59,6 +59,7 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0'
implementation 'org.telegram:telegrambots:6.9.7.1'
implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
@@ -0,0 +1,18 @@
package stirling.software.SPDF.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
@Configuration
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramBotConfig {
@Bean
public TelegramBotsApi telegramBotsApi() throws TelegramApiException {
return new TelegramBotsApi(DefaultBotSession.class);
}
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.service.telegram;
/**
* Enumeration representing different feedback types for Telegram service.
*
* @since 2.2.x
*/
public enum FeedbackEnum {
/** Indicates that the provided document is not valid. */
NO_VALID_DOCUMENT,
/** Represents a generic error message. */
ERROR_MESSAGE,
/** Indicates that an error occurred during processing. */
ERROR_PROCESSING,
/** Indicates that processing is ongoing. */
PROCESSING
}
@@ -0,0 +1,524 @@
package stirling.software.SPDF.service.telegram;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.api.methods.GetFile;
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Chat;
import org.telegram.telegrambots.meta.api.objects.Document;
import org.telegram.telegrambots.meta.api.objects.File;
import org.telegram.telegrambots.meta.api.objects.InputFile;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Telegram bot that processes incoming files through a defined pipeline.
*
* @since 2.2.x
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramPipelineBot extends TelegramLongPollingBot {
private static final String CHAT_PRIVATE = "private";
private static final String CHAT_GROUP = "group";
private static final String CHAT_SUPERGROUP = "supergroup";
private static final String CHAT_CHANNEL = "channel";
private static final Set<String> SUPPORTED_CHAT_TYPES =
Set.of(CHAT_PRIVATE, CHAT_GROUP, CHAT_SUPERGROUP, CHAT_CHANNEL);
private static final Set<String> ALLOWED_MIME_TYPES = Set.of("application/pdf");
private final Object pipelinePollMonitor = new Object();
private final ApplicationProperties.Telegram telegramProperties;
private final RuntimePathConfig runtimePathConfig;
private final TelegramBotsApi telegramBotsApi;
public TelegramPipelineBot(
ApplicationProperties applicationProperties,
RuntimePathConfig runtimePathConfig,
TelegramBotsApi telegramBotsApi) {
super(applicationProperties.getTelegram().getBotToken());
this.telegramProperties = applicationProperties.getTelegram();
this.runtimePathConfig = runtimePathConfig;
this.telegramBotsApi = telegramBotsApi;
}
@PostConstruct
public void register() {
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
return;
}
try {
telegramBotsApi.registerBot(this);
log.info("Telegram pipeline bot registered as {}", getBotUsername());
} catch (TelegramApiException e) {
log.error("Failed to register Telegram bot", e);
}
}
@Override
public void onUpdateReceived(Update update) {
Message message = extractMessage(update);
if (message == null) {
return;
}
Chat chat = message.getChat();
if (chat == null || !isSupportedChatType(chat.getType())) {
log.info(
"Ignoring message {}, unsupported chat type {}",
message.getMessageId(),
chat != null ? chat.getType() : "null");
return;
}
if (!isAuthorized(message, chat)) {
return;
}
if (update.hasMessage() && update.getMessage().hasText()) {
String messageText = update.getMessage().getText();
long chatId = update.getMessage().getChatId();
if ("/start".equals(messageText)) {
sendMessage(
chatId,
"""
Welcome to the SPDF Telegram Bot!
To get started, please send me a PDF document that you would like to process.
Make sure the document is in PDF format.
Once I receive your document, I'll begin processing it through the pipeline.
""");
return;
}
}
if (message.hasDocument()) {
handleIncomingFile(message);
return;
}
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chat.getType())) {
sendMessage(
chat.getId(),
"No valid file found in the message. Please send a document to process.");
}
}
private boolean feedback(FeedbackEnum feedbackEnum, String chatType) {
return switch (feedbackEnum) {
case NO_VALID_DOCUMENT ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getNoValidDocument();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getNoValidDocument();
default -> true;
};
case ERROR_MESSAGE ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getErrorMessage();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getErrorMessage();
default -> true;
};
case ERROR_PROCESSING ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getErrorProcessing();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getErrorProcessing();
default -> true;
};
case PROCESSING ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getProcessing();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getProcessing();
default -> true;
};
default -> true;
};
}
// ---------------------------
// Message Extraction / Chat Type
// ---------------------------
private Message extractMessage(Update update) {
if (update.hasMessage()) return update.getMessage();
if (update.hasChannelPost()) return update.getChannelPost();
return null;
}
private boolean isSupportedChatType(String type) {
return type != null && SUPPORTED_CHAT_TYPES.contains(type);
}
// ---------------------------
// Authorization
// ---------------------------
private boolean isAuthorized(Message message, Chat chat) {
if (!(telegramProperties.getEnableAllowUserIDs()
|| telegramProperties.getEnableAllowChannelIDs())) {
return true;
}
return switch (chat.getType()) {
case CHAT_CHANNEL -> checkChannelAccess(message, chat);
case CHAT_PRIVATE -> checkUserAccess(message, chat);
case CHAT_GROUP, CHAT_SUPERGROUP -> true; // groups allowed by default
default -> false;
};
}
private boolean checkUserAccess(Message message, Chat chat) {
if (!telegramProperties.getEnableAllowUserIDs()) return true;
User from = message.getFrom();
List<Long> allow = telegramProperties.getAllowUserIDs();
if (allow.isEmpty()) {
log.warn("No allowed user IDs configured - allowing all users.");
return true;
}
if (from == null || !allow.contains(from.getId())) {
log.info(
"Rejecting user {} in private chat {}",
from != null ? from.getId() : "unknown",
chat.getId());
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
sendMessage(chat.getId(), "You are not authorized to use this bot.");
}
return false;
}
return true;
}
private boolean checkChannelAccess(Message message, Chat chat) {
if (!telegramProperties.getEnableAllowChannelIDs()) return true;
Chat senderChat = message.getSenderChat();
List<Long> allow = telegramProperties.getAllowChannelIDs();
if (allow.isEmpty()) {
log.warn("No allowed channel IDs configured - allowing all channels.");
return true;
}
if (senderChat == null || !allow.contains(senderChat.getId())) {
log.info(
"Rejecting channel {} in chat {}",
senderChat != null ? senderChat.getId() : "unknown",
chat.getId());
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
sendMessage(chat.getId(), "This channel is not authorized to use this bot.");
}
return false;
}
return true;
}
// ---------------------------
// File Handling
// ---------------------------
private void handleIncomingFile(Message message) {
Long chatId = message.getChatId();
Document doc = message.getDocument();
String chatType = message.getChat().getType();
if (doc == null) {
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
sendMessage(chatId, "No document found.");
}
return;
}
if (doc.getMimeType() != null
&& !ALLOWED_MIME_TYPES.contains(doc.getMimeType().toLowerCase())) {
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
sendMessage(
chatId,
"Unsupported MIME type: "
+ doc.getMimeType()
+ "\nAllowed: "
+ String.join(", ", ALLOWED_MIME_TYPES));
}
return;
}
if (!hasJsonConfig(chatId)) {
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
sendMessage(
chatId,
"No JSON configuration file found in the pipeline inbox folder. Please"
+ " contact the administrator.");
}
return;
}
try {
if (!CHAT_CHANNEL.equalsIgnoreCase(chatType)
&& feedback(FeedbackEnum.PROCESSING, chatType)) {
sendMessage(chatId, "File received. Starting processing...");
}
PipelineFileInfo info = downloadMessageFile(message);
List<Path> outputs = waitForPipelineOutputs(info);
if (outputs.isEmpty()) {
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
sendMessage(
chatId,
"No results were found in the pipeline output folder. Check"
+ " configuration.");
}
return;
}
for (Path file : outputs) {
SendDocument out = new SendDocument();
out.setChatId(chatId);
out.setDocument(new InputFile(file.toFile(), file.getFileName().toString()));
execute(out);
}
} catch (TelegramApiException e) {
log.error("Telegram API error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "Telegram API error occurred.");
}
} catch (IOException e) {
log.error("IO error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "An IO error occurred.");
}
} catch (Exception e) {
log.error("Unexpected error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "Unexpected error occurred.");
}
}
}
private PipelineFileInfo downloadMessageFile(Message message)
throws TelegramApiException, IOException {
Document document = message.getDocument();
String filename = document.getFileName();
String name =
StringUtils.isNotBlank(filename) ? filename : document.getFileUniqueId() + ".bin";
return downloadFile(document.getFileId(), name, message);
}
private PipelineFileInfo downloadFile(String fileId, String originalName, Message message)
throws TelegramApiException, IOException {
Long chatId = message.getChatId();
Path inboxFolder = getInboxFolder(chatId);
GetFile getFile = new GetFile(fileId);
File tgFile = execute(getFile);
if (tgFile == null || StringUtils.isBlank(tgFile.getFilePath())) {
throw new IOException("Telegram did not return a file path.");
}
URL url = buildDownloadUrl(tgFile.getFilePath());
String base = FilenameUtils.getBaseName(originalName) + "-" + UUID.randomUUID();
String ext = FilenameUtils.getExtension(originalName);
String outFile = ext.isBlank() ? base : base + "." + ext;
Path targetFile = inboxFolder.resolve(outFile);
try (InputStream in = url.openStream()) {
Files.copy(in, targetFile);
}
log.info("Saved Telegram file {} to {}", originalName, targetFile);
return new PipelineFileInfo(targetFile, base, Instant.now());
}
private URL buildDownloadUrl(String filePath) throws MalformedURLException {
try {
URI uri =
new URI(
"https",
"api.telegram.org",
"/file/bot" + getBotToken() + "/" + filePath,
null);
return uri.toURL();
} catch (URISyntaxException e) {
throw new MalformedURLException("Failed to build Telegram download URL");
} catch (MalformedURLException e) {
MalformedURLException sanitized =
new MalformedURLException("Failed to build Telegram download URL");
sanitized.initCause(e);
throw sanitized;
}
}
// ---------------------------
// Inbox-Ordner & JSON-Check
// ---------------------------
private Path getInboxFolder(Long chatId) throws IOException {
Path baseInbox =
Paths.get(
runtimePathConfig.getPipelineWatchedFoldersPath(),
telegramProperties.getPipelineInboxFolder());
Files.createDirectories(baseInbox);
Path inboxFolder =
telegramProperties.getCustomFolderSuffix()
? baseInbox.resolve(chatId.toString())
: baseInbox;
Files.createDirectories(inboxFolder);
return inboxFolder;
}
private boolean hasJsonConfig(Long chatId) {
try {
Path inboxFolder = getInboxFolder(chatId);
try (Stream<Path> s = Files.list(inboxFolder)) {
return s.anyMatch(p -> p.toString().endsWith(".json"));
}
} catch (IOException e) {
log.error("Failed to check JSON config for chat {}", chatId, e);
return false;
}
}
// ---------------------------
// Pipeline polling
// ---------------------------
private List<Path> waitForPipelineOutputs(PipelineFileInfo info) throws IOException {
Path finishedDir = Paths.get(runtimePathConfig.getPipelineFinishedFoldersPath());
Files.createDirectories(finishedDir);
Instant start = info.savedAt();
Duration timeout = Duration.ofSeconds(telegramProperties.getProcessingTimeoutSeconds());
Duration poll = Duration.ofMillis(telegramProperties.getPollingIntervalMillis());
List<Path> results = new ArrayList<>();
while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) {
try (Stream<Path> s = Files.list(finishedDir)) {
results =
s.filter(Files::isRegularFile)
.filter(path -> matchesBaseName(info.uniqueBaseName(), path))
.filter(path -> isNewerThan(path, start))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
if (!results.isEmpty()) {
break;
}
synchronized (pipelinePollMonitor) {
try {
pipelinePollMonitor.wait(poll.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return results;
}
private boolean matchesBaseName(String base, Path file) {
return file.getFileName().toString().contains(base);
}
private boolean isNewerThan(Path path, Instant since) {
try {
return Files.getLastModifiedTime(path).toInstant().isAfter(since);
} catch (IOException e) {
log.info("Could not read modification time for {}", path);
return false;
}
}
// ---------------------------
// Messaging
// ---------------------------
private void sendMessage(Long chatId, String text) {
if (chatId == null) return;
SendMessage msg = new SendMessage();
msg.setChatId(chatId);
msg.setText(text);
try {
execute(msg);
} catch (TelegramApiException e) {
log.warn("Failed to send message to {}", chatId, e);
}
}
private record PipelineFileInfo(Path originalFile, String uniqueBaseName, Instant savedAt) {}
@Override
public String getBotUsername() {
return telegramProperties.getBotUsername();
}
@Override
public String getBotToken() {
return telegramProperties.getBotToken();
}
}
@@ -16,30 +16,30 @@ security:
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
initialLogin:
username: '' # initial username for the first login
password: '' # initial password for the first login
username: "" # initial username for the first login
password: "" # initial password for the first login
oauth2:
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
client:
keycloak:
issuer: '' # URL of the Keycloak realm's OpenID Connect Discovery endpoint
clientId: '' # client ID for Keycloak OAuth2
clientSecret: '' # client secret for Keycloak OAuth2
issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint
clientId: "" # client ID for Keycloak OAuth2
clientSecret: "" # client secret for Keycloak OAuth2
scopes: openid, profile, email # scopes for Keycloak OAuth2
useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2. Available options are: [email | name | given_name | family_name | preferred_name]
google:
clientId: '' # client ID for Google OAuth2
clientSecret: '' # client secret for Google OAuth2
clientId: "" # client ID for Google OAuth2
clientSecret: "" # client secret for Google OAuth2
scopes: email, profile # scopes for Google OAuth2
useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name]
github:
clientId: '' # client ID for GitHub OAuth2
clientSecret: '' # client secret for GitHub OAuth2
clientId: "" # client ID for GitHub OAuth2
clientSecret: "" # client secret for GitHub OAuth2
scopes: read:user # scope for GitHub OAuth2
useAsUsername: login # field to use as the username for GitHub OAuth2. Available options are: [email | login | name]
issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
clientId: '' # client ID from your Provider
clientSecret: '' # client secret from your Provider
issuer: "" # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
clientId: "" # client ID from your Provider
clientSecret: "" # client secret from your Provider
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
useAsUsername: email # default is 'email'; custom fields can be used as the username
@@ -47,14 +47,14 @@ security:
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
saml2:
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
provider: '' # The name of your Provider
provider: "" # The name of your Provider
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
idpIssuer: '' # The ID of your Provider
idpIssuer: "" # The ID of your Provider
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
@@ -110,21 +110,45 @@ mail:
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
host: smtp.example.com # SMTP server hostname
port: 587 # SMTP server port
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
username: "" # SMTP server username
password: "" # SMTP server password
from: "" # sender email address
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslTrust: "" # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
telegram:
enabled: false # set to 'true' to enable Telegram bot integration
botToken: "" # Telegram bot token obtained from BotFather
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
channel:
noValidDocument: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
errorProcessing: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
errorMessage: true # set to 'false' to hide/suppress error messages in channels (to avoid spam)
processing: true # set to 'false' to hide/suppress processing messages in channels (to avoid spam)
user:
noValidDocument: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
errorProcessing: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
errorMessage: true # set to 'false' to hide/suppress error messages to users (to avoid spam)
processing: true # set to 'false' to hide/suppress processing messages to users (to avoid spam)
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
system:
defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc)
@@ -143,8 +167,8 @@ system:
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
mobileScannerSettings:
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
@@ -162,14 +186,14 @@ system:
level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions)
allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com'])
blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org'])
internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns
internalTlds: [".local", ".internal", ".corp", ".home"] # Block domains with these TLD patterns
blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1)
blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10)
blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254)
datasource:
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration
customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
customDatabaseUrl: "" # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
username: postgres # set the database username
password: postgres # set the database password
type: postgresql # the type of the database to set (e.g. 'h2', 'postgresql')
@@ -178,29 +202,29 @@ system:
name: postgres # set the name of your database. Should match the name of the database you create
customPaths:
pipeline:
watchedFoldersDir: '' # Defaults to /pipeline/watchedFolders
finishedFoldersDir: '' # Defaults to /pipeline/finishedFolders
watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders
finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders
operations:
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
calibre: '' # Defaults to /usr/bin/ebook-convert
ocrmypdf: '' # Defaults to /usr/bin/ocrmypdf
soffice: '' # Defaults to /usr/bin/soffice
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
weasyprint: "" # Defaults to /opt/venv/bin/weasyprint
unoconvert: "" # Defaults to /opt/venv/bin/unoconvert
calibre: "" # Defaults to /usr/bin/ebook-convert
ocrmypdf: "" # Defaults to /usr/bin/ocrmypdf
soffice: "" # Defaults to /usr/bin/soffice
fileUploadLimit: "" # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
tempFileManagement:
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf
libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice
systemTempDir: '' # Only used if cleanupSystemTemp is true
baseTmpDir: "" # Defaults to java.io.tmpdir/stirling-pdf
libreofficeDir: "" # Defaults to tempFileManagement.baseTmpDir/libreoffice
systemTempDir: "" # Only used if cleanupSystemTemp is true
prefix: stirling-pdf- # Prefix for temp file names
maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up
cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes)
startupCleanup: true # Clean up old temp files on startup
cleanupSystemTemp: false # Whether to clean broader system temp directory
databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
ui:
appNameNavbar: '' # name displayed on the navigation bar
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
@@ -581,6 +581,7 @@ public class AdminSettingsController {
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
case "legal" -> applicationProperties.getLegal();
case "telegram" -> applicationProperties.getTelegram();
default -> null;
};
}
@@ -602,7 +603,8 @@ public class AdminSettingsController {
"processexecutor",
"autoPipeline",
"autopipeline",
"legal");
"legal",
"telegram");
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
private static final Pattern SAFE_KEY_PATTERN =
+125 -28
View File
@@ -82,7 +82,7 @@ incorrectPasswordMessage = "Das Passwort ist falsch."
usernameExistsMessage = "Neuer Benutzername existiert bereits."
invalidUsernameMessage = "Ungültiger Benutzername. Der Benutzername darf nur Buchstaben, Zahlen und die folgenden Sonderzeichen @._+- enthalten oder muss eine gültige E-Mail-Adresse sein."
invalidPasswordMessage = "Das Passwort darf nicht leer sein und kein Leerzeichen am Anfang und Ende haben."
confirmPasswordErrorMessage = "Neues Passwort und Neues Passwort bestätigen müssen übereinstimmen."
confirmPasswordErrorMessage = "\"Neues Passwort\" und \"Neues Passwort bestätigen\" müssen übereinstimmen."
deleteCurrentUserMessage = "Der aktuell angemeldete Benutzer kann nicht gelöscht werden."
deleteUsernameExistsMessage = "Der Benutzername existiert nicht und kann nicht gelöscht werden."
downgradeCurrentUserMessage = "Die Rolle des aktuellen Benutzers kann nicht herabgestuft werden"
@@ -363,6 +363,7 @@ advanced = "Erweitert"
title = "Sicherheit & Authentifizierung"
security = "Sicherheit"
connections = "Verbindungen"
telegram = "Telegram"
[settings.licensingAnalytics]
title = "Lizenzierung & Analytics"
@@ -2316,7 +2317,7 @@ colorLabel = "Textfarbe"
heading = "Gespeicherte Unterschriften"
description = "Gespeicherte Unterschriften jederzeit wiederverwenden."
emptyTitle = "Noch keine gespeicherten Unterschriften"
emptyDescription = "Zeichnen, laden oder tippen Sie oben eine Unterschrift und wählen Sie dann In Bibliothek speichern, um bis zu {{max}} Favoriten bereitzuhalten."
emptyDescription = "Zeichnen, laden oder tippen Sie oben eine Unterschrift und wählen Sie dann \"In Bibliothek speichern\", um bis zu {{max}} Favoriten bereitzuhalten."
limitTitle = "Limit erreicht"
limitDescription = "Entfernen Sie eine gespeicherte Unterschrift, bevor Sie neue hinzufügen (max. {{max}})."
carouselPosition = "{{current}} von {{total}}"
@@ -2526,7 +2527,7 @@ title = "Über Anmerkungen entfernen"
description = "Dieses Werkzeug entfernt alle Anmerkungen (Kommentare, Hervorhebungen, Notizen usw.) aus Ihren PDF-Dokumenten."
[removeAnnotations.tooltip.header]
title = "Über Anmerkungen entfernen"
title = "Über \"Anmerkungen entfernen\""
[removeAnnotations.tooltip.description]
title = "Funktion"
@@ -3849,11 +3850,6 @@ failed = "Ein Fehler ist beim Komprimieren der PDF aufgetreten."
_value = "Kompressionseinstellungen"
1 = "1-3 PDF-Komprimierung, </br> 4-6 Leichte Bildkomprimierung, </br> 7-9 Intensive Bildkomprimierung verringert die Bildqualität dramatisch"
[compress.compressionLevel]
range1to3 = "Lower values preserve quality but result in larger files"
range4to6 = "Medium compression with moderate quality reduction"
range7to9 = "Higher values reduce file size significantly but may reduce image clarity"
[compress.lineArt]
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
detailLevel = "Detail level"
@@ -3867,6 +3863,11 @@ unavailable = "ImageMagick is not installed or enabled on this server"
[compress.linearize]
label = "Linearize PDF for fast web viewing"
[compress.compressionLevel]
range1to3 = "Lower values preserve quality but result in larger files"
range4to6 = "Medium compression with moderate quality reduction"
range7to9 = "Higher values reduce file size significantly but may reduce image clarity"
[decrypt]
passwordPrompt = "Diese Datei ist passwortgeschützt. Bitte geben Sie das Passwort ein:"
cancelled = "Vorgang für PDF abgebrochen: {0}"
@@ -4020,7 +4021,7 @@ showPreferencesBtn = "Einstellungen verwalten"
[cookieBanner.popUp.description]
1 = "Wir verwenden Cookies und andere Technologien, damit Stirling PDF für Sie besser funktioniert. Dies hilft uns dabei, unsere Tools zu verbessern und weiterhin Funktionen zu entwickeln, die Ihnen gefallen werden."
2 = "Wenn Sie dies nicht möchten, klicken Sie auf Nein, Danke. Dadurch werden nur die unbedingt erforderlichen Cookies aktiviert, die für einen reibungslosen Ablauf erforderlich sind."
2 = "Wenn Sie dies nicht möchten, klicken Sie auf \"Nein, Danke\". Dadurch werden nur die unbedingt erforderlichen Cookies aktiviert, die für einen reibungslosen Ablauf erforderlich sind."
[cookieBanner.preferencesModal]
title = "Einwilligungszentrum"
@@ -4119,13 +4120,13 @@ toggleSidebar = "Seitenleiste umschalten"
exportSelected = "Ausgewählte Seiten exportieren"
toggleAnnotations = "Anmerkungen ein-/ausblenden"
print = "PDF drucken"
draw = "Zeichnen"
save = "Speichern"
saveChanges = "Änderungen speichern"
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
draw = "Draw"
exitRedaction = "Exit Redaction Mode"
redact = "Redact"
save = "Save"
[search]
title = "PDF durchsuchen"
@@ -4574,6 +4575,102 @@ pageFormatA4 = "A4 (210×297mm)"
pageFormatKeep = "Keep (Original Dimensions)"
pageFormatLetter = "Letter (8.5×11in)"
[admin.settings.telegram]
title = "Telegram-Bot"
description = "Telegram-Bot-Anbindung, Zugriffskontrollen und Feedback-Verhalten konfigurieren."
[admin.settings.telegram.enabled]
label = "Telegram-Bot aktivieren"
description = "Erlaubt Benutzern die Interaktion mit Stirling PDF über den konfigurierten Telegram-Bot."
[admin.settings.telegram.botUsername]
label = "Bot-Benutzername"
description = "Der öffentliche Benutzername Ihres Telegram-Bots."
[admin.settings.telegram.botToken]
label = "Bot-Token"
description = "API-Token, das von BotFather für Ihren Telegram-Bot bereitgestellt wird."
[admin.settings.telegram.pipelineInboxFolder]
label = "Posteingangsordner"
description = "Ordner innerhalb des Pipeline-Verzeichnisses, in dem eingehende Telegram-Dateien gespeichert werden."
[admin.settings.telegram.customFolderSuffix]
label = "Benutzerdefinierten Ordner-Suffix verwenden"
description = "Hängt die Chat-ID an eingehende Dateiordner an, um Uploads pro Chat zu isolieren."
[admin.settings.telegram.accessControl]
title = "Zugriffskontrolle"
description = "Einschränken, welche Benutzer oder Kanäle mit dem Bot interagieren dürfen."
[admin.settings.telegram.enableAllowUserIDs]
label = "Bestimmte Benutzer-IDs erlauben"
description = "Wenn aktiviert, dürfen nur aufgeführte Benutzer-IDs den Bot verwenden."
[admin.settings.telegram.allowUserIDs]
label = "Erlaubte Benutzer-IDs"
description = "Telegram-Benutzer-IDs, die mit dem Bot interagieren dürfen."
placeholder = "Benutzer-ID hinzufügen und Enter drücken"
[admin.settings.telegram.enableAllowChannelIDs]
label = "Bestimmte Kanal-IDs erlauben"
description = "Wenn aktiviert, dürfen nur aufgeführte Kanal-IDs den Bot verwenden."
[admin.settings.telegram.allowChannelIDs]
label = "Erlaubte Kanal-IDs"
description = "Telegram-Kanal-IDs, die mit dem Bot interagieren dürfen."
placeholder = "Kanal-ID hinzufügen und Enter drücken"
[admin.settings.telegram.processing]
title = "Verarbeitung"
description = "Polling-Intervalle und Verarbeitungs-Timeouts für Telegram-Uploads steuern."
[admin.settings.telegram.processingTimeoutSeconds]
label = "Verarbeitungs-Timeout (Sekunden)"
description = "Maximale Wartezeit für einen Verarbeitungsjob, bevor ein Fehler gemeldet wird."
[admin.settings.telegram.pollingIntervalMillis]
label = "Polling-Intervall (ms)"
description = "Intervall zwischen Prüfungen auf neue Telegram-Updates."
[admin.settings.telegram.feedback]
title = "Feedback-Nachrichten"
description = "Festlegen, wann der Bot Feedback an Benutzer und Kanäle senden soll."
[admin.settings.telegram.feedback.general.enabled]
label = "Feedback aktivieren"
description = "Legt fest, ob der Bot überhaupt Feedback-Nachrichten sendet."
[admin.settings.telegram.feedback.channel]
title = "Kanal-Feedback-Regeln"
[admin.settings.telegram.feedback.channel.noValidDocument]
label = "\"Kein gültiges Dokument\" einblenden (Kanal)"
description = "Sendet die Meldung \"Kein gültiges Dokument\" bei Kanal-Uploads."
[admin.settings.telegram.feedback.channel.errorProcessing]
label = "Verarbeitungsfehler einblenden (Kanal)"
description = "Sendet Verarbeitungsfehlermeldungen an Kanäle senden."
[admin.settings.telegram.feedback.channel.errorMessage]
label = "Fehlermeldungen einblenden (Kanal)"
description = "Detaillierte Fehlermeldungen für Kanäle einblenden."
[admin.settings.telegram.feedback.user]
title = "Benutzer-Feedback-Regeln"
[admin.settings.telegram.feedback.user.noValidDocument]
label = "\"Kein gültiges Dokument\" einblenden (Benutzer)"
description = "Sendet die Meldung \"Kein gültiges Dokument\" bei Benutzer-Uploads."
[admin.settings.telegram.feedback.user.errorProcessing]
label = "Verarbeitungsfehler einblenden (Benutzer)"
description = "Sendet Verarbeitungsfehlermeldungen an Benutzer."
[admin.settings.telegram.feedback.user.errorMessage]
label = "Fehlermeldungen einblenden (Benutzer)"
description = "Detaillierte Fehlermeldungen für Benutzer einblenden."
[admin.settings.database]
title = "Datenbank"
description = "Benutzerdefinierte Datenbankverbindungseinstellungen für Enterprise-Bereitstellungen konfigurieren."
@@ -4842,11 +4939,11 @@ description = "Optionale Funktionen und Features konfigurieren."
[admin.settings.features.serverCertificate]
label = "Serverzertifikat"
description = "Serverseitige Zertifikatserstellung für die Funktion Mit Stirling-PDF signieren konfigurieren"
description = "Serverseitige Zertifikatserstellung für die Funktion \"Mit Stirling-PDF signieren\" konfigurieren"
[admin.settings.features.serverCertificate.enabled]
label = "Serverzertifikat aktivieren"
description = "Serverseitiges Zertifikat für die Option Mit Stirling-PDF signieren aktivieren"
description = "Serverseitiges Zertifikat für die Option \"Mit Stirling-PDF signieren\" aktivieren"
[admin.settings.features.serverCertificate.organizationName]
label = "Name der Organisation"
@@ -5328,13 +5425,13 @@ confirmPrompt = "Sind Sie sicher, dass Sie fortfahren möchten?"
confirmCta = "Schlüssel aktualisieren"
[config.apiKeys.alert]
apiKeyErrorTitle = "API-Schlüssel-Fehler"
failedToCreateApiKey = "API-Schlüssel konnte nicht erstellt werden."
failedToRetrieveApiKey = "API-Schlüssel konnte nicht aus der Antwort abgerufen werden."
failedToFetchApiKey = "API-Schlüssel konnte nicht abgerufen werden."
apiKeyRefreshed = "API-Schlüssel aktualisiert"
apiKeyRefreshedBody = "Ihr API-Schlüssel wurde erfolgreich aktualisiert."
failedToRefreshApiKey = "API-Schlüssel konnte nicht aktualisiert werden."
apiKeyErrorTitle = "API Key Error"
apiKeyRefreshed = "API Key Refreshed"
apiKeyRefreshedBody = "Your API key has been successfully refreshed."
failedToCreateApiKey = "Failed to create API key."
failedToFetchApiKey = "Failed to fetch API key."
failedToRefreshApiKey = "Failed to refresh API key."
failedToRetrieveApiKey = "Failed to retrieve API key from response."
[AddAttachmentsRequest]
attachments = "Anhänge auswählen"
@@ -5346,7 +5443,7 @@ selectedFiles = "Ausgewählte Dateien"
submit = "Anhänge hinzufügen"
[AddAttachmentsRequest.tooltip.header]
title = "Über Anhänge hinzufügen"
title = "Über \"Anhänge hinzufügen\""
[AddAttachmentsRequest.tooltip.description]
title = "Funktion"
@@ -5355,7 +5452,7 @@ title = "Funktion"
title = "Anhangs-Ergebnisse"
[AddAttachmentsRequest.error]
failed = "Operation Anhänge hinzufügen fehlgeschlagen"
failed = "Operation \"Anhänge hinzufügen\" fehlgeschlagen"
[addAttachments.error]
failed = "Beim Hinzufügen von Anhängen zur PDF ist ein Fehler aufgetreten."
@@ -6473,7 +6570,6 @@ pen = "Pen"
polygon = "Polygon"
rectangle = "Rectangle"
redo = "Redo"
saveChanges = "Save Changes"
saveFailed = "Unable to save copy"
saveReady = "Download ready"
savingCopy = "Preparing download..."
@@ -6496,11 +6592,17 @@ title = "Annotate"
underline = "Underline"
undo = "Undo"
unsupportedType = "This annotation type is not fully supported for editing."
saveChanges = "Save Changes"
[footer]
discord = "Discord"
issues = "GitHub"
[textAlign]
center = "Center"
left = "Left"
right = "Right"
[mobileScanner]
addToBatch = "Add to Batch"
back = "Back"
@@ -6555,8 +6657,3 @@ pollingError = "Error checking for files"
sessionCreateError = "Failed to create session"
sessionId = "Session ID"
title = "Upload from Mobile"
[textAlign]
center = "Center"
left = "Left"
right = "Right"
@@ -367,6 +367,7 @@ advanced = "Advanced"
title = "Security & Authentication"
security = "Security"
connections = "Connections"
telegram = "Telegram"
[settings.licensingAnalytics]
title = "Licensing & Analytics"
@@ -4633,6 +4634,90 @@ description = "Automatically create user accounts on first SAML2 login"
label = "Block Registration"
description = "Prevent new user registration via SAML2"
[admin.settings.telegram]
title = "Telegram Bot"
description = "Configure Telegram bot connectivity, access controls, and feedback behavior."
[admin.settings.telegram.enabled]
label = "Enable Telegram Bot"
description = "Allow users to interact with Stirling PDF through your configured Telegram bot."
[admin.settings.telegram.botUsername]
label = "Bot Username"
description = "The public username of your Telegram bot."
[admin.settings.telegram.botToken]
label = "Bot Token"
description = "API token provided by BotFather for your Telegram bot."
[admin.settings.telegram.pipelineInboxFolder]
label = "Inbox Folder"
description = "Folder under the pipeline directory where incoming Telegram files are stored."
[admin.settings.telegram.customFolderSuffix]
label = "Use Custom Folder Suffix"
description = "Append the chat ID to incoming file folders to isolate uploads per chat."
[admin.settings.telegram.accessControl]
title = "Access Control"
description = "Restrict which users or channels can interact with the bot."
[admin.settings.telegram.enableAllowUserIDs]
label = "Allow Specific User IDs"
description = "When enabled, only listed user IDs can use the bot."
[admin.settings.telegram.allowUserIDs]
label = "Allowed User IDs"
description = "Enter Telegram user IDs allowed to interact with the bot."
placeholder = "Add user ID and press enter"
[admin.settings.telegram.enableAllowChannelIDs]
label = "Allow Specific Channel IDs"
description = "When enabled, only listed channel IDs can use the bot."
[admin.settings.telegram.allowChannelIDs]
label = "Allowed Channel IDs"
description = "Enter Telegram channel IDs allowed to interact with the bot."
placeholder = "Add channel ID and press enter"
[admin.settings.telegram.processing]
title = "Processing"
description = "Control polling intervals and processing timeouts for Telegram uploads."
[admin.settings.telegram.processingTimeoutSeconds]
label = "Processing Timeout (seconds)"
description = "Maximum time to wait for a processing job before reporting an error."
[admin.settings.telegram.pollingIntervalMillis]
label = "Polling Interval (ms)"
description = "Interval between checks for new Telegram updates."
[admin.settings.telegram.feedback]
title = "Feedback Messages"
description = "Choose when the bot should send feedback to users and channels."
[admin.settings.telegram.feedback.general.enabled]
label = "Enable Feedback"
description = "Control whether the bot sends feedback messages at all."
[admin.settings.telegram.feedback.channel]
title = "Channel Feedback Rules"
noValidDocument.label = "Show \"No valid document\" (Channel)"
noValidDocument.description = "Suppress the no valid document response for channel uploads."
errorProcessing.label = "Show processing errors (Channel)"
errorProcessing.description = "Send processing error messages to channels."
errorMessage.label = "Show error messages (Channel)"
errorMessage.description = "Show detailed error messages for channels."
[admin.settings.telegram.feedback.user]
title = "User Feedback Rules"
noValidDocument.label = "Show \"No valid document\" (User)"
noValidDocument.description = "Suppress the no valid document response for user uploads."
errorProcessing.label = "Show processing errors (User)"
errorProcessing.description = "Send processing error messages to users."
errorMessage.label = "Show error messages (User)"
errorMessage.description = "Show detailed error messages for users."
[admin.settings.connections.mobileScanner]
label = "Mobile Phone Upload"
enable = "Enable QR Code Upload"
@@ -1,5 +1,18 @@
import { useEffect, useState } from 'react';
import { Paper, Group, Text, Button, Collapse, Stack, TextInput, Textarea, Switch, PasswordInput } from '@mantine/core';
import {
Paper,
Group,
Text,
Button,
Collapse,
Stack,
TextInput,
Textarea,
Switch,
PasswordInput,
NumberInput,
TagsInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
@@ -105,6 +118,36 @@ export default function ProviderCard({
/>
);
case 'number':
return (
<NumberInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(num) => handleFieldChange(field.key, num)}
disabled={disabled}
allowDecimal={false}
/>
);
case 'tags': {
const tagValue = Array.isArray(value) ? value.map((val) => `${val}`) : [];
return (
<TagsInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={tagValue}
onChange={(vals) => handleFieldChange(field.key, vals)}
disabled={disabled}
/>
);
}
default:
return (
<TextInput
@@ -1,8 +1,10 @@
export type ProviderType = 'oauth2' | 'saml2';
import { useTranslation } from 'react-i18next';
export type ProviderType = 'oauth2' | 'saml2' | 'telegram';
export interface ProviderField {
key: string;
type: 'text' | 'password' | 'switch' | 'textarea';
type: 'text' | 'password' | 'switch' | 'textarea' | 'number' | 'tags';
label: string;
description: string;
placeholder?: string;
@@ -231,7 +233,7 @@ export const SMTP_PROVIDER: Provider = {
},
{
key: 'port',
type: 'text',
type: 'number',
label: 'SMTP Port',
description: 'The port number for SMTP connection (typically 25, 465, or 587)',
placeholder: '587',
@@ -258,6 +260,216 @@ export const SMTP_PROVIDER: Provider = {
},
],
};
const useTelegramProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'telegram',
name: t('admin.settings.telegram.title', 'Telegram Bot'),
icon: 'send-rounded',
type: 'telegram',
scope: t(
'admin.settings.telegram.description',
'Configure Telegram bot connectivity, access controls, and feedback behavior.'
),
fields: [
{
key: 'enabled',
type: 'switch',
label: t('admin.settings.telegram.enabled.label', 'Enable Telegram Bot'),
description: t(
'admin.settings.telegram.enabled.description',
'Allow users to interact with Stirling PDF through your configured Telegram bot.'
),
defaultValue: false,
},
{
key: 'botUsername',
type: 'text',
label: t('admin.settings.telegram.botUsername.label', 'Bot Username'),
description: t(
'admin.settings.telegram.botUsername.description',
'The public username of your Telegram bot.'
),
placeholder: 'my_pdf_bot',
},
{
key: 'botToken',
type: 'password',
label: t('admin.settings.telegram.botToken.label', 'Bot Token'),
description: t(
'admin.settings.telegram.botToken.description',
'API token provided by BotFather for your Telegram bot.'
),
placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
},
{
key: 'pipelineInboxFolder',
type: 'text',
label: t('admin.settings.telegram.pipelineInboxFolder.label', 'Inbox Folder'),
description: t(
'admin.settings.telegram.pipelineInboxFolder.description',
'Folder under the pipeline directory where incoming Telegram files are stored.'
),
placeholder: 'telegram',
},
{
key: 'customFolderSuffix',
type: 'switch',
label: t('admin.settings.telegram.customFolderSuffix.label', 'Use Custom Folder Suffix'),
description: t(
'admin.settings.telegram.customFolderSuffix.description',
'Append the chat ID to incoming file folders to isolate uploads per chat.'
),
defaultValue: false,
},
{
key: 'enableAllowUserIDs',
type: 'switch',
label: t('admin.settings.telegram.enableAllowUserIDs.label', 'Allow Specific User IDs'),
description: t(
'admin.settings.telegram.enableAllowUserIDs.description',
'When enabled, only listed user IDs can use the bot.'
),
defaultValue: false,
},
{
key: 'allowUserIDs',
type: 'tags',
label: t('admin.settings.telegram.allowUserIDs.label', 'Allowed User IDs'),
description: t(
'admin.settings.telegram.allowUserIDs.description',
'Enter Telegram user IDs allowed to interact with the bot.'
),
placeholder: t('admin.settings.telegram.allowUserIDs.placeholder', 'Add user ID and press enter'),
defaultValue: [],
},
{
key: 'enableAllowChannelIDs',
type: 'switch',
label: t('admin.settings.telegram.enableAllowChannelIDs.label', 'Allow Specific Channel IDs'),
description: t(
'admin.settings.telegram.enableAllowChannelIDs.description',
'When enabled, only listed channel IDs can use the bot.'
),
defaultValue: false,
},
{
key: 'allowChannelIDs',
type: 'tags',
label: t('admin.settings.telegram.allowChannelIDs.label', 'Allowed Channel IDs'),
description: t(
'admin.settings.telegram.allowChannelIDs.description',
'Enter Telegram channel IDs allowed to interact with the bot.'
),
placeholder: t('admin.settings.telegram.allowChannelIDs.placeholder', 'Add channel ID and press enter'),
defaultValue: [],
},
{
key: 'processingTimeoutSeconds',
type: 'number',
label: t(
'admin.settings.telegram.processingTimeoutSeconds.label',
'Processing Timeout (seconds)'
),
description: t(
'admin.settings.telegram.processingTimeoutSeconds.description',
'Maximum time to wait for a processing job before reporting an error.'
),
defaultValue: 180,
},
{
key: 'pollingIntervalMillis',
type: 'number',
label: t('admin.settings.telegram.pollingIntervalMillis.label', 'Polling Interval (ms)'),
description: t(
'admin.settings.telegram.pollingIntervalMillis.description',
'Interval between checks for new Telegram updates.'
),
defaultValue: 2000,
},
{
key: 'feedback.general.enabled',
type: 'switch',
label: t('admin.settings.telegram.feedback.general.enabled.label', 'Enable Feedback'),
description: t(
'admin.settings.telegram.feedback.general.enabled.description',
'Control whether the bot sends feedback messages at all.'
),
defaultValue: true,
},
{
key: 'feedback.channel.noValidDocument',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.noValidDocument.label',
'Show "No valid document" (Channel)'
),
description: t(
'admin.settings.telegram.feedback.channel.noValidDocument.description',
'Suppress the no valid document response for channel uploads.'
),
defaultValue: false,
},
{
key: 'feedback.channel.errorProcessing',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.errorProcessing.label',
'Show processing errors (Channel)'
),
description: t(
'admin.settings.telegram.feedback.channel.errorProcessing.description',
'Send processing error messages to channels.'
),
defaultValue: false,
},
{
key: 'feedback.channel.errorMessage',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.errorMessage.label',
'Show error messages (Channel)'
),
description: t(
'admin.settings.telegram.feedback.channel.errorMessage.description',
'Show detailed error messages for channels.'
),
defaultValue: false,
},
{
key: 'feedback.user.noValidDocument',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.noValidDocument.label', 'Show "No valid document" (User)'),
description: t(
'admin.settings.telegram.feedback.user.noValidDocument.description',
'Suppress the no valid document response for user uploads.'
),
defaultValue: false,
},
{
key: 'feedback.user.errorProcessing',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.errorProcessing.label', 'Show processing errors (User)'),
description: t(
'admin.settings.telegram.feedback.user.errorProcessing.description',
'Send processing error messages to users.'
),
defaultValue: false,
},
{
key: 'feedback.user.errorMessage',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.errorMessage.label', 'Show error messages (User)'),
description: t(
'admin.settings.telegram.feedback.user.errorMessage.description',
'Show detailed error messages for users.'
),
defaultValue: false,
},
],
};
};
export const SAML2_PROVIDER: Provider = {
id: 'saml2',
@@ -352,4 +564,14 @@ export const SAML2_PROVIDER: Provider = {
],
};
export const ALL_PROVIDERS = [...OAUTH2_PROVIDERS, GENERIC_OAUTH2_PROVIDER, SAML2_PROVIDER, SMTP_PROVIDER];
export const useAllProviders = (): Provider[] => {
const telegramProvider = useTelegramProvider();
return [
...OAUTH2_PROVIDERS,
GENERIC_OAUTH2_PROVIDER,
SAML2_PROVIDER,
SMTP_PROVIDER,
telegramProvider,
];
};
@@ -18,6 +18,7 @@ export const VALID_NAV_KEYS = [
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminTelegram',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
@@ -10,14 +10,38 @@ import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
import {
ALL_PROVIDERS,
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface FeedbackFlags {
noValidDocument?: boolean;
errorProcessing?: boolean;
errorMessage?: boolean;
}
interface FeedbackSettings {
general?: { enabled?: boolean };
channel?: FeedbackFlags;
user?: FeedbackFlags;
}
interface TelegramSettingsData {
enabled?: boolean;
botToken?: string;
botUsername?: string;
pipelineInboxFolder?: string;
customFolderSuffix?: boolean;
enableAllowUserIDs?: boolean;
allowUserIDs?: number[];
enableAllowChannelIDs?: boolean;
allowChannelIDs?: number[];
processingTimeoutSeconds?: number;
pollingIntervalMillis?: number;
feedback?: FeedbackSettings;
}
interface ConnectionsSettingsData {
oauth2?: {
enabled?: boolean;
@@ -45,6 +69,7 @@ interface ConnectionsSettingsData {
password?: string;
from?: string;
};
telegram?: TelegramSettingsData;
ssoAutoLogin?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
@@ -58,6 +83,7 @@ export default function AdminConnectionsSection() {
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
@@ -74,6 +100,10 @@ export default function AdminConnectionsSection() {
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
const premiumData = premiumResponse.data || {};
// Fetch Telegram settings
const telegramResponse = await apiClient.get('/api/v1/admin/settings/section/telegram');
const telegramData = telegramResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = systemResponse.data || {};
@@ -82,6 +112,7 @@ export default function AdminConnectionsSection() {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
telegram: telegramData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false,
mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false,
@@ -101,6 +132,9 @@ export default function AdminConnectionsSection() {
if (mailData._pending) {
pendingBlock.mail = mailData._pending;
}
if (telegramData._pending) {
pendingBlock.telegram = telegramData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
}
@@ -162,6 +196,10 @@ export default function AdminConnectionsSection() {
return settings?.mail?.enabled === true;
}
if (provider.id === 'telegram') {
return settings?.telegram?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings?.oauth2?.enabled === true;
}
@@ -180,6 +218,10 @@ export default function AdminConnectionsSection() {
return settings?.mail || {};
}
if (provider.id === 'telegram') {
return settings?.telegram || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
@@ -210,6 +252,35 @@ export default function AdminConnectionsSection() {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings);
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} else if (provider.id === 'telegram') {
const parseToNumberArray = (values: any) =>
(Array.isArray(values) ? values : [])
.map((value) => Number(value))
.filter((value) => !Number.isNaN(value));
const response = await apiClient.put('/api/v1/admin/settings/section/telegram', {
...providerSettings,
allowUserIDs: parseToNumberArray(providerSettings.allowUserIDs),
allowChannelIDs: parseToNumberArray(providerSettings.allowChannelIDs),
processingTimeoutSeconds: providerSettings.processingTimeoutSeconds
? Number(providerSettings.processingTimeoutSeconds)
: undefined,
pollingIntervalMillis: providerSettings.pollingIntervalMillis
? Number(providerSettings.pollingIntervalMillis)
: undefined,
});
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
@@ -271,11 +342,27 @@ export default function AdminConnectionsSection() {
return;
}
try{
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
if (response.status === 200) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
} else if (provider.id === 'telegram') {
const response = await apiClient.put('/api/v1/admin/settings/section/telegram', {
enabled: false,
});
if (response.status === 200) {
await fetchSettings();
alert({
@@ -425,8 +512,8 @@ export default function AdminConnectionsSection() {
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
return (
<Stack gap="xl">