feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish
- Remove top paginator from dashboard events, keep only bottom - Fix test message locale: pass UI locale to email/matrix bot tests - Convert webhook auth mode from text input to icon grid selector - Generate secure UUID tokens for webhook URLs instead of sequential IDs - Move Recent Payloads into per-provider expandable container (lazy-loaded) - Make template config languages dynamic via app settings instead of hardcoded - Change default dev port to 5175
This commit is contained in:
@@ -58,6 +58,14 @@ export const memorySourceItems = (): GridItem[] => [
|
|||||||
{ value: 'native', icon: 'mdiMemory', label: t('trackingConfig.memorySourceNative'), desc: t('gridDesc.memorySourceNative') },
|
{ value: 'native', icon: 'mdiMemory', label: t('trackingConfig.memorySourceNative'), desc: t('gridDesc.memorySourceNative') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Webhook auth mode ---
|
||||||
|
|
||||||
|
export const webhookAuthModeItems = (): GridItem[] => [
|
||||||
|
{ value: 'none', icon: 'mdiLockOpen', label: t('providers.authNone'), desc: t('gridDesc.authNone') },
|
||||||
|
{ value: 'bearer_token', icon: 'mdiKey', label: t('providers.authBearer'), desc: t('gridDesc.authBearer') },
|
||||||
|
{ value: 'hmac_sha256', icon: 'mdiShieldKey', label: t('providers.authHmac'), desc: t('gridDesc.authHmac') },
|
||||||
|
];
|
||||||
|
|
||||||
// --- Locale ---
|
// --- Locale ---
|
||||||
|
|
||||||
export const localeItems = (): GridItem[] => [
|
export const localeItems = (): GridItem[] => [
|
||||||
|
|||||||
@@ -133,6 +133,9 @@
|
|||||||
"plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).",
|
"plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).",
|
||||||
"authMode": "Authentication Mode",
|
"authMode": "Authentication Mode",
|
||||||
"authModeHint": "Choose hmac_sha256, bearer_token, or none",
|
"authModeHint": "Choose hmac_sha256, bearer_token, or none",
|
||||||
|
"authNone": "None",
|
||||||
|
"authBearer": "Bearer Token",
|
||||||
|
"authHmac": "HMAC-SHA256",
|
||||||
"genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.",
|
"genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.",
|
||||||
"maxStoredPayloads": "Max stored payloads",
|
"maxStoredPayloads": "Max stored payloads",
|
||||||
"maxStoredPayloadsHint": "Number of recent payloads to keep for debugging (0 = disabled, max 100)",
|
"maxStoredPayloadsHint": "Number of recent payloads to keep for debugging (0 = disabled, max 100)",
|
||||||
@@ -658,6 +661,9 @@
|
|||||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||||
"cacheTtl": "Media Cache TTL (hours)",
|
"cacheTtl": "Media Cache TTL (hours)",
|
||||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||||
|
"locales": "Template Languages",
|
||||||
|
"supportedLocales": "Supported Locales",
|
||||||
|
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -926,6 +932,9 @@
|
|||||||
"message_ups_overload": "UPS load exceeded capacity"
|
"message_ups_overload": "UPS load exceeded capacity"
|
||||||
},
|
},
|
||||||
"gridDesc": {
|
"gridDesc": {
|
||||||
|
"authNone": "No authentication required",
|
||||||
|
"authBearer": "Verify requests with a Bearer token",
|
||||||
|
"authHmac": "Verify payload signature with HMAC-SHA256",
|
||||||
"sortNone": "No sorting applied",
|
"sortNone": "No sorting applied",
|
||||||
"sortDate": "Sort by creation date",
|
"sortDate": "Sort by creation date",
|
||||||
"sortRating": "Sort by star rating",
|
"sortRating": "Sort by star rating",
|
||||||
|
|||||||
@@ -133,6 +133,9 @@
|
|||||||
"plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).",
|
"plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).",
|
||||||
"authMode": "Режим аутентификации",
|
"authMode": "Режим аутентификации",
|
||||||
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
|
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
|
||||||
|
"authNone": "Без аутентификации",
|
||||||
|
"authBearer": "Bearer Token",
|
||||||
|
"authHmac": "HMAC-SHA256",
|
||||||
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
|
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
|
||||||
"maxStoredPayloads": "Макс. сохранённых запросов",
|
"maxStoredPayloads": "Макс. сохранённых запросов",
|
||||||
"maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)",
|
"maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)",
|
||||||
@@ -658,6 +661,9 @@
|
|||||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||||
"cacheTtl": "TTL кэша медиа (часы)",
|
"cacheTtl": "TTL кэша медиа (часы)",
|
||||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||||
|
"locales": "Языки шаблонов",
|
||||||
|
"supportedLocales": "Поддерживаемые локали",
|
||||||
|
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -926,6 +932,9 @@
|
|||||||
"message_ups_overload": "ИБП перегружен"
|
"message_ups_overload": "ИБП перегружен"
|
||||||
},
|
},
|
||||||
"gridDesc": {
|
"gridDesc": {
|
||||||
|
"authNone": "Аутентификация не требуется",
|
||||||
|
"authBearer": "Проверка запросов по Bearer-токену",
|
||||||
|
"authHmac": "Проверка подписи через HMAC-SHA256",
|
||||||
"sortNone": "Без сортировки",
|
"sortNone": "Без сортировки",
|
||||||
"sortDate": "По дате создания",
|
"sortDate": "По дате создания",
|
||||||
"sortRating": "По рейтингу",
|
"sortRating": "По рейтингу",
|
||||||
|
|||||||
@@ -56,5 +56,5 @@ export const giteaDescriptor: ProviderDescriptor = {
|
|||||||
desc: () => '',
|
desc: () => '',
|
||||||
},
|
},
|
||||||
|
|
||||||
webhookUrlPattern: '/api/webhooks/gitea/{id}',
|
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,5 +62,5 @@ export const plankaDescriptor: ProviderDescriptor = {
|
|||||||
desc: () => '',
|
desc: () => '',
|
||||||
},
|
},
|
||||||
|
|
||||||
webhookUrlPattern: '/api/webhooks/planka/{id}',
|
webhookUrlPattern: '/api/webhooks/planka/{token}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export interface ConfigField {
|
|||||||
configKey?: string;
|
configKey?: string;
|
||||||
/** i18n key for the field label. */
|
/** i18n key for the field label. */
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'password' | 'number';
|
type: 'text' | 'password' | 'number' | 'grid-select';
|
||||||
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
|
gridItems?: string;
|
||||||
|
gridColumns?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/**
|
/**
|
||||||
* - `true` → always required
|
* - `true` → always required
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export const webhookDescriptor: ProviderDescriptor = {
|
|||||||
{
|
{
|
||||||
key: 'auth_mode', configKey: 'auth_mode',
|
key: 'auth_mode', configKey: 'auth_mode',
|
||||||
label: 'providers.authMode',
|
label: 'providers.authMode',
|
||||||
type: 'text',
|
type: 'grid-select',
|
||||||
placeholder: 'hmac_sha256 | bearer_token | none',
|
gridItems: 'webhookAuthModeItems',
|
||||||
|
gridColumns: 3,
|
||||||
defaultValue: 'none',
|
defaultValue: 'none',
|
||||||
hint: 'providers.authModeHint',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'webhook_secret', configKey: 'webhook_secret',
|
key: 'webhook_secret', configKey: 'webhook_secret',
|
||||||
@@ -57,6 +57,6 @@ export const webhookDescriptor: ProviderDescriptor = {
|
|||||||
|
|
||||||
collectionMeta: null,
|
collectionMeta: null,
|
||||||
webhookBased: true,
|
webhookBased: true,
|
||||||
webhookUrlPattern: '/api/webhooks/webhook/{id}',
|
webhookUrlPattern: '/api/webhooks/webhook/{token}',
|
||||||
payloadHistory: true,
|
payloadHistory: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ export const capabilitiesCache = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/** Supported template locales — fetched from app settings. */
|
||||||
|
export const supportedLocalesCache = (() => {
|
||||||
|
let data = $state<string[]>(['en', 'ru']);
|
||||||
|
let fetchedAt = $state(0);
|
||||||
|
const TTL = 300_000; // 5 minutes
|
||||||
|
return {
|
||||||
|
get items() { return data; },
|
||||||
|
async fetch(force = false): Promise<string[]> {
|
||||||
|
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||||
|
const { api } = await import('$lib/api');
|
||||||
|
data = await api('/settings/locales');
|
||||||
|
fetchedAt = Date.now();
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All caches keyed by entity type — for search palette and crosslink resolution.
|
* All caches keyed by entity type — for search palette and crosslink resolution.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface ServiceProvider {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
webhook_token: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,10 +319,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
{@render paginator()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if eventsLoading}
|
{#if eventsLoading}
|
||||||
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
||||||
{:else if status.recent_events.length === 0}
|
{:else if status.recent_events.length === 0}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
async function testEmailBot(botId: number) {
|
async function testEmailBot(botId: number) {
|
||||||
emailTesting = { ...emailTesting, [botId]: true };
|
emailTesting = { ...emailTesting, [botId]: true };
|
||||||
try {
|
try {
|
||||||
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
|
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||||
else snackError(res.error || t('emailBot.operationFailed'));
|
else snackError(res.error || t('emailBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
async function testMatrixBot(botId: number) {
|
async function testMatrixBot(botId: number) {
|
||||||
matrixTesting = { ...matrixTesting, [botId]: true };
|
matrixTesting = { ...matrixTesting, [botId]: true };
|
||||||
try {
|
try {
|
||||||
const res = await api(`/matrix-bots/${botId}/test`, { method: 'POST' });
|
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||||
else snackError(res.error || t('matrixBot.operationFailed'));
|
else snackError(res.error || t('matrixBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
import { commandTemplateConfigsCache } from '$lib/stores/caches.svelte';
|
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCALES = ['en', 'ru'] as const;
|
let LOCALES = $derived(supportedLocalesCache.items);
|
||||||
|
|
||||||
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
||||||
let filterText = $state('');
|
let filterText = $state('');
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
commandTemplateConfigsCache.fetch(true),
|
commandTemplateConfigsCache.fetch(true),
|
||||||
api('/providers/capabilities'),
|
api('/providers/capabilities'),
|
||||||
api('/command-template-configs/variables'),
|
api('/command-template-configs/variables'),
|
||||||
|
supportedLocalesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
allCmdTplConfigs = cfgs;
|
allCmdTplConfigs = cfgs;
|
||||||
allCapabilities = caps;
|
allCapabilities = caps;
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
import { providerTypeItems, providerDefaultIcon } from '$lib/grid-items';
|
import { providerTypeItems, providerDefaultIcon, webhookAuthModeItems } from '$lib/grid-items';
|
||||||
|
|
||||||
|
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
@@ -189,7 +191,9 @@
|
|||||||
{t(editing && field.editLabel ? field.editLabel : field.label)}
|
{t(editing && field.editLabel ? field.editLabel : field.label)}
|
||||||
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
|
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
|
||||||
</label>
|
</label>
|
||||||
{#if field.type === 'number'}
|
{#if field.type === 'grid-select' && field.gridItems}
|
||||||
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||||
|
{:else if field.type === 'number'}
|
||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||||
min={field.min} max={field.max}
|
min={field.min} max={field.max}
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
@@ -207,7 +211,7 @@
|
|||||||
{#if descriptor?.webhookUrlPattern && editing}
|
{#if descriptor?.webhookUrlPattern && editing}
|
||||||
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
||||||
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
|
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
|
||||||
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{id}', String(editing))}</code>
|
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -254,7 +258,7 @@
|
|||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if provDesc?.webhookUrlPattern}
|
{#if provDesc?.webhookUrlPattern}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{id}', String(provider.id))}</span></p>
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,10 +267,10 @@
|
|||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if provDesc?.payloadHistory && !showForm}
|
||||||
|
<WebhookPayloadHistory providerId={provider.id} />
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
{#if provDesc?.payloadHistory && !showForm}
|
|
||||||
<WebhookPayloadHistory providerId={provider.id} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import type { WebhookPayloadLog } from '$lib/types';
|
import type { WebhookPayloadLog } from '$lib/types';
|
||||||
|
|
||||||
@@ -12,7 +11,9 @@
|
|||||||
let { providerId }: Props = $props();
|
let { providerId }: Props = $props();
|
||||||
|
|
||||||
let logs = $state<WebhookPayloadLog[]>([]);
|
let logs = $state<WebhookPayloadLog[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(false);
|
||||||
|
let loaded = $state(false);
|
||||||
|
let expanded = $state(false);
|
||||||
let expandedId = $state<number | null>(null);
|
let expandedId = $state<number | null>(null);
|
||||||
let showClearConfirm = $state(false);
|
let showClearConfirm = $state(false);
|
||||||
|
|
||||||
@@ -25,6 +26,12 @@
|
|||||||
logs = [];
|
logs = [];
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
expanded = !expanded;
|
||||||
|
if (expanded && !loaded) loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearLogs() {
|
async function clearLogs() {
|
||||||
@@ -60,102 +67,96 @@
|
|||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void providerId;
|
|
||||||
loadLogs();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<div class="border-t border-[var(--color-border)] mt-3 pt-2">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<button onclick={toggle} class="w-full flex items-center gap-2 text-sm font-medium py-1 hover:opacity-80 transition-opacity">
|
||||||
<div class="flex items-center gap-2">
|
<MdiIcon name={expanded ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||||
<h3 class="text-sm font-semibold">{t('webhookLogs.title')}</h3>
|
{t('webhookLogs.title')}
|
||||||
{#if logs.length > 0}
|
{#if loaded && logs.length > 0}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{logs.length}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{logs.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="mt-2">
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-sm text-[var(--color-muted-foreground)] py-3 text-center">{t('common.loading')}</div>
|
||||||
|
{:else if logs.length === 0}
|
||||||
|
<div class="text-sm text-[var(--color-muted-foreground)] py-3 text-center">{t('webhookLogs.empty')}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex justify-end mb-2">
|
||||||
|
<button
|
||||||
|
onclick={() => showClearConfirm = true}
|
||||||
|
class="text-xs px-2 py-1 rounded-md transition-colors hover:bg-[var(--color-error-bg)] text-[var(--color-error-fg)]"
|
||||||
|
>
|
||||||
|
{t('webhookLogs.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each logs as log}
|
||||||
|
<button
|
||||||
|
onclick={() => toggleExpand(log.id)}
|
||||||
|
class="w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--color-sidebar-active)]"
|
||||||
|
style="background: {expandedId === log.id ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MdiIcon name={expandedId === log.id ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
||||||
|
<span class="text-xs text-[var(--color-muted-foreground)] tabular-nums">{formatTime(log.created_at)}</span>
|
||||||
|
<span class="text-xs font-mono px-1 py-0.5 rounded bg-[var(--color-muted)]">{log.method}</span>
|
||||||
|
<span class="flex items-center gap-1" style="color: {statusColor(log.status)};">
|
||||||
|
<MdiIcon name={statusIcon(log.status)} size={14} />
|
||||||
|
<span class="text-xs font-medium">{statusLabel(log.status)}</span>
|
||||||
|
</span>
|
||||||
|
{#if log.error_message && log.status === 'error'}
|
||||||
|
<span class="text-xs text-[var(--color-muted-foreground)] truncate max-w-[200px]">{log.error_message}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expandedId === log.id}
|
||||||
|
<div class="ml-6 mr-2 mb-2 space-y-2">
|
||||||
|
{#if Object.keys(log.headers).length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.headers')}</div>
|
||||||
|
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
|
||||||
|
{#each Object.entries(log.headers) as [key, value]}
|
||||||
|
<div><span class="text-[var(--color-primary)]">{key}</span>: {value}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.body')}</div>
|
||||||
|
<pre class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all" style="max-height: 300px; overflow-y: auto;">{JSON.stringify(log.body, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if log.status === 'matched' && Object.keys(log.extracted_fields).length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.extractedFields')}</div>
|
||||||
|
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
|
||||||
|
{#each Object.entries(log.extracted_fields) as [key, value]}
|
||||||
|
<div><span class="text-[var(--color-primary)]">{key}</span>: {typeof value === 'object' ? JSON.stringify(value) : String(value)}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if log.status === 'error' && log.error_message}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-[var(--color-error-fg)] mb-1">{t('webhookLogs.errorMessage')}</div>
|
||||||
|
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] rounded-md p-2 text-xs">{log.error_message}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if logs.length > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => showClearConfirm = true}
|
|
||||||
class="text-xs px-2 py-1 rounded-md transition-colors hover:bg-[var(--color-error-bg)] text-[var(--color-error-fg)]"
|
|
||||||
>
|
|
||||||
{t('webhookLogs.clear')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="text-sm text-[var(--color-muted-foreground)] py-4 text-center">{t('common.loading')}</div>
|
|
||||||
{:else if logs.length === 0}
|
|
||||||
<div class="text-sm text-[var(--color-muted-foreground)] py-4 text-center">{t('webhookLogs.empty')}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#each logs as log}
|
|
||||||
<button
|
|
||||||
onclick={() => toggleExpand(log.id)}
|
|
||||||
class="w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--color-sidebar-active)]"
|
|
||||||
style="background: {expandedId === log.id ? 'var(--color-sidebar-active)' : 'transparent'};"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<MdiIcon name={expandedId === log.id ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)] tabular-nums">{formatTime(log.created_at)}</span>
|
|
||||||
<span class="text-xs font-mono px-1 py-0.5 rounded bg-[var(--color-muted)]">{log.method}</span>
|
|
||||||
<span class="flex items-center gap-1" style="color: {statusColor(log.status)};">
|
|
||||||
<MdiIcon name={statusIcon(log.status)} size={14} />
|
|
||||||
<span class="text-xs font-medium">{statusLabel(log.status)}</span>
|
|
||||||
</span>
|
|
||||||
{#if log.error_message && log.status === 'error'}
|
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)] truncate max-w-[200px]">{log.error_message}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if expandedId === log.id}
|
|
||||||
<div class="ml-6 mr-2 mb-2 space-y-2">
|
|
||||||
<!-- Headers -->
|
|
||||||
{#if Object.keys(log.headers).length > 0}
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.headers')}</div>
|
|
||||||
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
|
|
||||||
{#each Object.entries(log.headers) as [key, value]}
|
|
||||||
<div><span class="text-[var(--color-primary)]">{key}</span>: {value}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.body')}</div>
|
|
||||||
<pre class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all" style="max-height: 300px; overflow-y: auto;">{JSON.stringify(log.body, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Extracted fields -->
|
|
||||||
{#if log.status === 'matched' && Object.keys(log.extracted_fields).length > 0}
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.extractedFields')}</div>
|
|
||||||
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
|
|
||||||
{#each Object.entries(log.extracted_fields) as [key, value]}
|
|
||||||
<div><span class="text-[var(--color-primary)]">{key}</span>: {typeof value === 'object' ? JSON.stringify(value) : String(value)}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
{#if log.status === 'error' && log.error_message}
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-[var(--color-error-fg)] mb-1">{t('webhookLogs.errorMessage')}</div>
|
|
||||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] rounded-md p-2 text-xs">{log.error_message}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={showClearConfirm}
|
open={showClearConfirm}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { providerTypeItems } from '$lib/grid-items';
|
import { providerTypeItems, webhookAuthModeItems } from '$lib/grid-items';
|
||||||
|
|
||||||
|
const gridItemSources: Record<string, () => any[]> = { webhookAuthModeItems };
|
||||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
@@ -100,7 +102,9 @@
|
|||||||
{t(field.label)}
|
{t(field.label)}
|
||||||
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
|
{#if field.optional}<span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span>{/if}
|
||||||
</label>
|
</label>
|
||||||
{#if field.type === 'number'}
|
{#if field.type === 'grid-select' && field.gridItems}
|
||||||
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||||
|
{:else if field.type === 'number'}
|
||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
external_url: '',
|
external_url: '',
|
||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '48',
|
telegram_cache_ttl_hours: '48',
|
||||||
|
supported_locales: 'en,ru',
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -79,6 +80,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Locales section -->
|
||||||
|
<Card>
|
||||||
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiTranslate" size={18} />
|
||||||
|
{t('settings.locales')}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||||
|
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
||||||
|
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Button onclick={save} disabled={saving}>
|
<Button onclick={save} disabled={saving}>
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
import { templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
let slotFilter = $state('');
|
let slotFilter = $state('');
|
||||||
let showPreviewFor = $state<Set<string>>(new Set());
|
let showPreviewFor = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
const LOCALES = ['en', 'ru'] as const;
|
let LOCALES = $derived(supportedLocalesCache.items);
|
||||||
let activeLocale = $state<string>('en');
|
let activeLocale = $state<string>('en');
|
||||||
|
|
||||||
function toggleSlot(key: string) {
|
function toggleSlot(key: string) {
|
||||||
@@ -202,6 +202,7 @@
|
|||||||
templateConfigsCache.fetch(true),
|
templateConfigsCache.fetch(true),
|
||||||
api('/template-configs/variables'),
|
api('/template-configs/variables'),
|
||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
|
supportedLocalesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { defineConfig } from 'vite';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8420',
|
'/api': 'http://localhost:8420',
|
||||||
'/docs': 'http://localhost:8420',
|
'/docs': 'http://localhost:8420',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..auth.dependencies import require_admin
|
from ..auth.dependencies import get_current_user, require_admin
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import AppSetting, TelegramBot, User
|
from ..database.models import AppSetting, TelegramBot, User
|
||||||
|
|
||||||
@@ -21,12 +21,14 @@ _SETTING_KEYS = {
|
|||||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||||
|
"supported_locales": None, # comma-separated locale codes
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
"external_url": "",
|
"external_url": "",
|
||||||
"telegram_webhook_secret": "",
|
"telegram_webhook_secret": "",
|
||||||
"telegram_cache_ttl_hours": "48",
|
"telegram_cache_ttl_hours": "48",
|
||||||
|
"supported_locales": "en,ru",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
external_url: str | None = None
|
external_url: str | None = None
|
||||||
telegram_webhook_secret: str | None = None
|
telegram_webhook_secret: str | None = None
|
||||||
telegram_cache_ttl_hours: str | None = None
|
telegram_cache_ttl_hours: str | None = None
|
||||||
|
supported_locales: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -105,6 +108,17 @@ async def update_settings(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locales")
|
||||||
|
async def get_supported_locales(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Return list of supported template locales (available to all users)."""
|
||||||
|
raw = await get_setting(session, "supported_locales")
|
||||||
|
locales = [loc.strip() for loc in raw.split(",") if loc.strip()]
|
||||||
|
return locales or ["en"]
|
||||||
|
|
||||||
|
|
||||||
async def _reregister_webhooks(
|
async def _reregister_webhooks(
|
||||||
session: AsyncSession, base_url: str, secret: str
|
session: AsyncSession, base_url: str, secret: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -117,12 +117,16 @@ async def delete_email_bot(
|
|||||||
@router.post("/{bot_id}/test")
|
@router.post("/{bot_id}/test")
|
||||||
async def test_email_bot(
|
async def test_email_bot(
|
||||||
bot_id: int,
|
bot_id: int,
|
||||||
|
locale: str = Query("en"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test email to the bot's own address to verify SMTP connection."""
|
"""Send a test email to the bot's own address to verify SMTP connection."""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
|
|
||||||
|
from ..services.notifier import _get_test_message
|
||||||
|
msg = _get_test_message(locale, "email")
|
||||||
|
|
||||||
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
|
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
|
||||||
client = EmailClient(SmtpConfig(
|
client = EmailClient(SmtpConfig(
|
||||||
host=bot.smtp_host,
|
host=bot.smtp_host,
|
||||||
@@ -135,8 +139,8 @@ async def test_email_bot(
|
|||||||
))
|
))
|
||||||
result = await client.send(
|
result = await client.send(
|
||||||
to_email=bot.email,
|
to_email=bot.email,
|
||||||
subject="Notify Bridge — Test Connection",
|
subject=f"Notify Bridge — {msg}",
|
||||||
body_text="This is a test email from Notify Bridge. Your SMTP settings are working correctly.",
|
body_text=msg,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -99,6 +99,7 @@ async def delete_matrix_bot(
|
|||||||
async def test_matrix_bot(
|
async def test_matrix_bot(
|
||||||
bot_id: int,
|
bot_id: int,
|
||||||
room_id: str = "",
|
room_id: str = "",
|
||||||
|
locale: str = Query("en"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
@@ -127,12 +128,14 @@ async def test_matrix_bot(
|
|||||||
|
|
||||||
# Optionally send a test message
|
# Optionally send a test message
|
||||||
if room_id:
|
if room_id:
|
||||||
|
from ..services.notifier import _get_test_message
|
||||||
from notify_bridge_core.notifications.matrix.client import MatrixClient
|
from notify_bridge_core.notifications.matrix.client import MatrixClient
|
||||||
|
msg = _get_test_message(locale, "matrix")
|
||||||
client = MatrixClient(http, bot.homeserver_url, bot.access_token)
|
client = MatrixClient(http, bot.homeserver_url, bot.access_token)
|
||||||
send_result = await client.send_message(
|
send_result = await client.send_message(
|
||||||
room_id,
|
room_id,
|
||||||
"Test message from Notify Bridge",
|
msg,
|
||||||
html_message="<b>Test message</b> from Notify Bridge",
|
html_message=f"<b>{msg}</b>",
|
||||||
)
|
)
|
||||||
result["send_result"] = send_result
|
result["send_result"] = send_result
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ def _provider_response(p: ServiceProvider) -> dict:
|
|||||||
"name": p.name,
|
"name": p.name,
|
||||||
"icon": p.icon,
|
"icon": p.icon,
|
||||||
"config": config,
|
"config": config,
|
||||||
|
"webhook_token": p.webhook_token,
|
||||||
"created_at": p.created_at.isoformat(),
|
"created_at": p.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_provider_by_token(
|
||||||
|
session: AsyncSession, token: str, expected_type: str,
|
||||||
|
) -> ServiceProvider:
|
||||||
|
"""Look up a provider by its webhook_token and expected type."""
|
||||||
|
result = await session.exec(
|
||||||
|
select(ServiceProvider).where(
|
||||||
|
ServiceProvider.webhook_token == token,
|
||||||
|
ServiceProvider.type == expected_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = result.first()
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HMAC-SHA256 validation
|
# HMAC-SHA256 validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -168,16 +184,14 @@ async def _dispatch_webhook_event(
|
|||||||
# Gitea webhook endpoint
|
# Gitea webhook endpoint
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/gitea/{provider_id}")
|
@router.post("/gitea/{token}")
|
||||||
async def gitea_webhook(provider_id: int, request: Request):
|
async def gitea_webhook(token: str, request: Request):
|
||||||
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
|
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
|
|
||||||
# --- Load provider and validate signature ---
|
# --- Load provider and validate signature ---
|
||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
provider = await session.get(ServiceProvider, provider_id)
|
provider = await _get_provider_by_token(session, token, "gitea")
|
||||||
if not provider or provider.type != "gitea":
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
||||||
|
|
||||||
@@ -211,7 +225,7 @@ async def gitea_webhook(provider_id: int, request: Request):
|
|||||||
# --- Dispatch ---
|
# --- Dispatch ---
|
||||||
dispatched = await _dispatch_webhook_event(
|
dispatched = await _dispatch_webhook_event(
|
||||||
engine=engine,
|
engine=engine,
|
||||||
provider_id=provider_id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
provider_config=provider.config or {},
|
provider_config=provider.config or {},
|
||||||
event=event,
|
event=event,
|
||||||
@@ -239,16 +253,14 @@ def _verify_planka_token(expected_token: str, request: Request) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/planka/{provider_id}")
|
@router.post("/planka/{token}")
|
||||||
async def planka_webhook(provider_id: int, request: Request):
|
async def planka_webhook(token: str, request: Request):
|
||||||
"""Receive a Planka webhook, parse it, filter, and dispatch notifications."""
|
"""Receive a Planka webhook, parse it, filter, and dispatch notifications."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
|
|
||||||
# --- Load provider and validate token ---
|
# --- Load provider and validate token ---
|
||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
provider = await session.get(ServiceProvider, provider_id)
|
provider = await _get_provider_by_token(session, token, "planka")
|
||||||
if not provider or provider.type != "planka":
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
||||||
|
|
||||||
@@ -279,7 +291,7 @@ async def planka_webhook(provider_id: int, request: Request):
|
|||||||
# --- Dispatch ---
|
# --- Dispatch ---
|
||||||
dispatched = await _dispatch_webhook_event(
|
dispatched = await _dispatch_webhook_event(
|
||||||
engine=engine,
|
engine=engine,
|
||||||
provider_id=provider_id,
|
provider_id=provider.id,
|
||||||
provider_name=provider.name,
|
provider_name=provider.name,
|
||||||
provider_config=provider.config or {},
|
provider_config=provider.config or {},
|
||||||
event=event,
|
event=event,
|
||||||
@@ -394,17 +406,16 @@ async def _save_webhook_log(
|
|||||||
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook/{provider_id}")
|
@router.post("/webhook/{token}")
|
||||||
async def generic_webhook(provider_id: int, request: Request):
|
async def generic_webhook(token: str, request: Request):
|
||||||
"""Receive a generic webhook, extract variables via JSONPath, and dispatch notifications."""
|
"""Receive a generic webhook, extract variables via JSONPath, and dispatch notifications."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
|
|
||||||
# --- Load provider and validate auth ---
|
# --- Load provider and validate auth ---
|
||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
provider = await session.get(ServiceProvider, provider_id)
|
provider = await _get_provider_by_token(session, token, "webhook")
|
||||||
if not provider or provider.type != "webhook":
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
|
provider_id = provider.id
|
||||||
provider_config = provider.config or {}
|
provider_config = provider.config or {}
|
||||||
provider_name = provider.name
|
provider_name = provider.name
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
telegram_webhook_secret: str = ""
|
telegram_webhook_secret: str = ""
|
||||||
|
|
||||||
cors_allowed_origins: str = "http://localhost:5173"
|
cors_allowed_origins: str = "http://localhost:5175"
|
||||||
"""Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com')."""
|
"""Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com')."""
|
||||||
|
|
||||||
static_dir: str = ""
|
static_dir: str = ""
|
||||||
|
|||||||
@@ -272,6 +272,24 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added commands_enabled column to telegram_chat table")
|
logger.info("Added commands_enabled column to telegram_chat table")
|
||||||
|
|
||||||
|
# Add webhook_token to service_provider if missing
|
||||||
|
if await _has_table(conn, "service_provider"):
|
||||||
|
if not await _has_column(conn, "service_provider", "webhook_token"):
|
||||||
|
await conn.execute(
|
||||||
|
text("ALTER TABLE service_provider ADD COLUMN webhook_token TEXT DEFAULT ''")
|
||||||
|
)
|
||||||
|
logger.info("Added webhook_token column to service_provider table")
|
||||||
|
# Backfill existing providers with unique tokens
|
||||||
|
import uuid
|
||||||
|
providers = (await conn.execute(text("SELECT id FROM service_provider"))).fetchall()
|
||||||
|
for row in providers:
|
||||||
|
await conn.execute(
|
||||||
|
text("UPDATE service_provider SET webhook_token = :tok WHERE id = :pid"),
|
||||||
|
{"tok": uuid.uuid4().hex, "pid": row[0]},
|
||||||
|
)
|
||||||
|
if providers:
|
||||||
|
logger.info("Backfilled webhook_token for %d existing providers", len(providers))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Legacy tracker_target migration (pre-Phase 1)
|
# Legacy tracker_target migration (pre-Phase 1)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class ServiceProvider(SQLModel, table=True):
|
|||||||
name: str
|
name: str
|
||||||
icon: str = Field(default="")
|
icon: str = Field(default="")
|
||||||
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
webhook_token: str = Field(default_factory=lambda: uuid4().hex)
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ if [ -n "$PID" ]; then
|
|||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Resolve python — py launcher needs absolute path for nohup on Windows
|
# Resolve python — prefer py launcher on Windows (python3 may be the Store stub)
|
||||||
if command -v python3 &>/dev/null; then
|
if command -v py &>/dev/null; then
|
||||||
|
PYTHON=$(py -3 -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||||
|
elif command -v python3 &>/dev/null && python3 --version &>/dev/null; then
|
||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
elif command -v python &>/dev/null; then
|
elif command -v python &>/dev/null && python --version &>/dev/null; then
|
||||||
PYTHON=python
|
PYTHON=python
|
||||||
else
|
else
|
||||||
PYTHON=$(py -3.13 -c "import sys; print(sys.executable)" 2>/dev/null \
|
echo "Python not found"; exit 1
|
||||||
|| py -3 -c "import sys; print(sys.executable)")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start backend
|
# Start backend
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ set -e
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
# Kill existing frontend
|
# Kill existing frontend
|
||||||
PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1)
|
PID=$(netstat -ano 2>/dev/null | grep ':5175.*LISTENING' | awk '{print $5}' | head -1)
|
||||||
if [ -n "$PID" ]; then
|
if [ -n "$PID" ]; then
|
||||||
taskkill //F //PID "$PID" 2>/dev/null || true
|
taskkill //F //PID "$PID" 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
@@ -14,8 +14,8 @@ fi
|
|||||||
|
|
||||||
# Start frontend
|
# Start frontend
|
||||||
cd frontend
|
cd frontend
|
||||||
npx vite dev --port 5173 --host > /dev/null 2>&1 &
|
npx vite dev --port 5175 --host > /dev/null 2>&1 &
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
sleep 4
|
sleep 4
|
||||||
curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5173/
|
curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5175/
|
||||||
|
|||||||
Reference in New Issue
Block a user