diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 58c120329f..cc9a3bbfc4 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 allowUserIDs = new ArrayList<>(); + private Boolean enableAllowChannelIDs = false; + private List 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; diff --git a/app/core/build.gradle b/app/core/build.gradle index 86c002ff86..61c7b563c7 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -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" diff --git a/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java new file mode 100644 index 0000000000..92f126ed5b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java @@ -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); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/telegram/FeedbackEnum.java b/app/core/src/main/java/stirling/software/SPDF/service/telegram/FeedbackEnum.java new file mode 100644 index 0000000000..bd57e7a465 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/FeedbackEnum.java @@ -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 +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java new file mode 100644 index 0000000000..798f880a90 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java @@ -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 SUPPORTED_CHAT_TYPES = + Set.of(CHAT_PRIVATE, CHAT_GROUP, CHAT_SUPERGROUP, CHAT_CHANNEL); + + private static final Set 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 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 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 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 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 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 results = new ArrayList<>(); + + while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) { + try (Stream 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(); + } +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5f68e65e56..bb0cbac5d2 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index f250d60ccf..28a580c746 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 = diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index dd18085b5a..41eb3f4a27 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -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,
4-6 Leichte Bildkomprimierung,
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" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 499b407a91..8d6c49d1a6 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx index 15eaadb415..710ae71dbf 100644 --- a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -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 ( + handleFieldChange(field.key, num)} + disabled={disabled} + allowDecimal={false} + /> + ); + + case 'tags': { + const tagValue = Array.isArray(value) ? value.map((val) => `${val}`) : []; + + return ( + handleFieldChange(field.key, vals)} + disabled={disabled} + /> + ); + } + default: return ( { + 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, + ]; +}; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 740e6130e7..9a73890516 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -18,6 +18,7 @@ export const VALID_NAV_KEYS = [ 'adminGeneral', 'adminSecurity', 'adminConnections', + 'adminTelegram', 'adminPrivacy', 'adminDatabase', 'adminAdvanced', @@ -33,4 +34,4 @@ export const VALID_NAV_KEYS = [ // Derive the type from the array export type NavKey = typeof VALID_NAV_KEYS[number]; -// some of these are not used yet, but appear in figma designs \ No newline at end of file +// some of these are not used yet, but appear in figma designs diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index 85a29c8f1a..c6c377539e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -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({ 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 (