Compare commits

..
3 Commits
Author SHA1 Message Date
3252a8 1d43606446 docs: drop manual image build instructions from README 2026-06-10 23:10:27 +03:00
3252a8 5ca1ecfce6 test: parse installer download host instead of substring check
Extract the raw_url() template from install.sh and compare the parsed
hostname to raw.githubusercontent.com. Resolves the CodeQL "incomplete
URL substring sanitization" alert on the old substring assertion.
2026-06-10 23:09:38 +03:00
3252a8 86ac925b19 ci: move dev Docker Hub publishing back to GitHub Actions
Publish dev images to both GHCR and Docker Hub from the dev workflow
and drop the GitLab CI pipeline, so all images are built by GitHub
Actions only.
2026-06-10 22:55:43 +03:00
12 changed files with 59 additions and 2027 deletions
+1 -2
View File
@@ -1,7 +1,7 @@
name: Dev images
# On every push to the dev branch, build all three images and push them to
# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI.
# GHCR and Docker Hub tagged `dev`.
on:
push:
@@ -22,5 +22,4 @@ jobs:
with:
push: true
tag_mode: dev
publish_dockerhub: false
secrets: inherit
-72
View File
@@ -1,72 +0,0 @@
stages:
- docker
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
- when: never
variables:
DOCKER_BUILDKIT: "1"
DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
GIT_DEPTH: "0"
docker-dev:
stage: docker
image: docker:27.5.1
services:
- name: docker:27.5.1-dind
alias: docker
interruptible: true
parallel:
matrix:
- TARGET: backend
IMAGE: remnawave-minishop-backend
- TARGET: worker
IMAGE: remnawave-minishop-worker
- TARGET: frontend
IMAGE: remnawave-minishop-frontend
before_script:
- apk add --no-cache git
- test -n "$DOCKERHUB_USERNAME"
- test -n "$DOCKERHUB_TOKEN"
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
- docker buildx create --name gitlab-builder --driver docker-container --use
- docker buildx inspect --bootstrap
script:
- git fetch origin dev --tags
- |
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
exit 0
fi
- |
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
if [ -z "$provenance" ]; then
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
provenance="official"
else
provenance="custom"
fi
fi
docker buildx build \
--load \
--platform linux/amd64 \
--file deploy/docker/Dockerfile \
--target "$TARGET" \
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
--build-arg "BUILDKIT_INLINE_CACHE=1" \
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
.
- git fetch origin dev --tags
- |
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
exit 0
fi
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
-12
View File
@@ -139,15 +139,3 @@ IMAGE_TAG=3.1.0 docker compose up -d
- `docker.io/3252a8/remnawave-minishop-backend`
- `docker.io/3252a8/remnawave-minishop-worker`
- `docker.io/3252a8/remnawave-minishop-frontend`
Сборка и публикация сразу в GHCR и Docker Hub:
```bash
docker login ghcr.io
docker login docker.io
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
```
## Поддержать проект
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
@@ -34,7 +34,7 @@ from bot.services.settings_override_service import (
current_value,
update_overrides,
)
from bot.utils import MessageContent, send_message_via_queue, SUPPORTED_PARAMS
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
@@ -7,11 +7,6 @@ from collections import defaultdict
from bot.utils.ttl_cache import AsyncTTLCache
import tempfile
import os
from aiogram.types import InputFile, Message
from aiogram.exceptions import TelegramBadRequest
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
BROADCAST_TARGETS = {
@@ -172,32 +167,11 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
# --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
media_type = payload.get("media_type") # например "photo", "video", "document" и т.д.
media_file_id = payload.get("media_file_id") # file_id или URL (строка)
# ---------------------------
if not text:
return _error(400, "empty_text")
if target not in BROADCAST_TARGETS:
target = "all"
# --- ЛОГИКА ФОРМИРОВАНИЯ MessageContent ---
if media_type and media_file_id:
# Отправляем медиа: текст становится подписью (caption)
if media_type not in SUPPORTED_PARAMS:
return _error(400, "unsupported_media_type")
content = MessageContent(
content_type=media_type,
file_id=media_file_id,
text=text, # будет использован как caption
)
else:
# Текстовое сообщение
if not text:
return _error(400, "empty_text")
content = MessageContent(content_type="text", text=text)
# --------------------------------------
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
@@ -227,11 +201,10 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
failed = 0
for uid in user_ids:
try:
# Передаём content (уже с нужным типом), а остальные параметры остаются
await send_message_via_queue(
queue_manager,
int(uid),
content,
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
@@ -245,13 +218,14 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} "
f"type={content.content_type} text={text[:120]}",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
"""Return how many users each broadcast audience currently resolves to."""
_require_admin_user_id(request)
@@ -266,67 +240,3 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res
)
return _ok({"counts": counts})
async def admin_upload_media_route(request: web.Request) -> web.Response:
"""
Эндпоинт для загрузки медиафайла администратором.
Принимает multipart/form-data с полем 'file'.
Возвращает file_id, полученный от Telegram Bot.
"""
actor_id = _require_admin_user_id(request)
# Проверяем, что это multipart-запрос
if request.content_type and not request.content_type.startswith("multipart/form-data"):
return _error(400, "invalid_content_type")
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "file":
return _error(400, "file_required")
# Сохраняем файл во временный файл
with tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") as tmp:
while True:
chunk = await field.read_chunk()
if not chunk:
break
tmp.write(chunk)
tmp_path = tmp.name
try:
# Получаем бота из app
bot = request.app.get("bot")
if bot is None:
return _error(503, "bot_unavailable")
# Отправляем файл в личный чат администратора, чтобы получить file_id
# Используем send_document, так как он подходит для любых файлов
with open(tmp_path, "rb") as f:
input_file = InputFile(f)
sent_msg: Message = await bot.send_document(
chat_id=actor_id,
document=input_file,
# Не отправляем лишний текст, чтобы не привлекать внимание
)
file_id = sent_msg.document.file_id
# Удаляем сообщение, чтобы не засорять чат
await bot.delete_message(chat_id=actor_id, message_id=sent_msg.message_id)
# Возвращаем file_id
return _ok({"file_id": file_id})
except TelegramBadRequest as e:
logger.warning("Failed to upload media to Telegram: %s", e)
return _error(400, f"telegram_error: {str(e)}")
except Exception as e:
logger.error("Unexpected error during media upload: %s", e, exc_info=True)
return _error(500, "upload_failed")
finally:
# Удаляем временный файл
try:
os.unlink(tmp_path)
except OSError:
pass
@@ -64,8 +64,6 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/upload-media", admin_upload_media_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
+23 -1413
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -42,7 +42,6 @@
"vite": "^8.0.12"
},
"dependencies": {
"qrcode": "^1.5.4",
"carta-md": "^4.11.2"
"qrcode": "^1.5.4"
}
}
@@ -1,10 +1,9 @@
<script>
import { Textarea } from "$components/ui/index.js";
import { Send } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { Send, Upload, Link as LinkIcon } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { Label } from "$components/ui/primitives.js";
import { Carta, MarkdownEditor } from "carta-md";
import "carta-md/default.css"; // Базовые стили
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
const broadcastStore = getContext("broadcastStore");
@@ -12,8 +11,6 @@
$: ({
broadcastTarget,
broadcastText,
broadcastMediaType,
broadcastMediaFileId,
broadcastBusy,
broadcastResult,
broadcastCounts,
@@ -22,29 +19,7 @@
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
// Настройка Carta
const carta = new Carta({
sanitizer: (html) => html, // Можно подключить DOMPurify для безопасности
});
// Опции типов медиа
const MEDIA_TYPE_OPTIONS = [
{ value: "", label: at("media_type_none", {}, "Без медиа") },
{ value: "photo", label: "📷 Фото" },
{ value: "video", label: "🎬 Видео" },
{ value: "document", label: "📄 Документ" },
{ value: "audio", label: "🎵 Аудио" },
{ value: "animation", label: "🎞️ Анимация (GIF)" },
{ value: "voice", label: "🎤 Голосовое" },
{ value: "sticker", label: "😊 Стикер" },
{ value: "video_note", label: "📹 Видео-заметка" },
];
// Локальное состояние для вкладок медиа
let mediaInputMode = "url"; // 'url' | 'file'
let selectedFile = null;
let isUploading = false;
// Append the resolved audience size to each option once counts are loaded.
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
const count = broadcastCounts?.[option.value];
if (count != null) return { ...option, label: `${option.label} (${count})` };
@@ -52,46 +27,6 @@
return option;
});
$: showFileIdInput = broadcastMediaType && broadcastMediaType !== "";
$: canSend = !broadcastBusy && (
(broadcastText && broadcastText.trim()) ||
(broadcastMediaType && broadcastMediaFileId && broadcastMediaFileId.trim())
);
// Обработчик загрузки файла
async function handleFileUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
selectedFile = file;
isUploading = true;
try {
// TODO: заменить на реальный эндпоинт загрузки
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/admin/upload-media", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error("Upload failed");
const data = await response.json();
broadcastStore.updateField({
broadcastMediaFileId: data.file_id || data.url,
});
} catch (error) {
console.error("Upload error:", error);
// Можно показать уведомление об ошибке
} finally {
isUploading = false;
// Сбрасываем input, чтобы можно было загрузить тот же файл снова
event.target.value = "";
}
}
onMount(() => {
broadcastStore.loadCounts();
});
@@ -102,10 +37,8 @@
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
</header>
<div class="admin-card-body">
<div class="admin-form">
<!-- Аудитория -->
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect
@@ -115,336 +48,37 @@
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
</Label.Root>
<!-- Тип медиа -->
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_media_type", {}, "Тип медиа")}</span>
<AdminSelect
value={broadcastMediaType}
items={MEDIA_TYPE_OPTIONS}
ariaLabel={at("broadcast_label_media_type", {}, "Тип медиа")}
onValueChange={(value) => {
broadcastStore.updateField({
broadcastMediaType: value,
// Очищаем file_id при смене типа
broadcastMediaFileId: "",
});
}}
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<Textarea
class="admin-textarea"
rows="6"
value={broadcastText}
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
/>
</Label.Root>
<!-- Блок загрузки медиа (показывается если выбран тип) -->
{#if showFileIdInput}
<div class="media-upload-block">
<!-- Переключатель режимов -->
<div class="media-tabs">
<button
class="media-tab {mediaInputMode === 'url' ? 'active' : ''}"
on:click={() => mediaInputMode = 'url'}
>
<LinkIcon size={14} />
{at("media_tab_url", {}, "По ссылке")}
</button>
<button
class="media-tab {mediaInputMode === 'file' ? 'active' : ''}"
on:click={() => mediaInputMode = 'file'}
>
<Upload size={14} />
{at("media_tab_file", {}, "Загрузить файл")}
</button>
</div>
<!-- Режим: ссылка -->
{#if mediaInputMode === 'url'}
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_file_id", {}, "File ID или URL")}</span>
<small>
{at("broadcast_hint_file_id", {},
"Введите file_id из Telegram или прямую ссылку на файл")}
</small>
<input
class="admin-input"
type="text"
placeholder="https://example.com/image.jpg или file_id"
value={broadcastMediaFileId}
on:input={(e) => broadcastStore.updateField({
broadcastMediaFileId: e.target.value
})}
/>
</Label.Root>
{/if}
<!-- Режим: загрузка файла -->
{#if mediaInputMode === 'file'}
<div class="file-upload-area">
<label class="file-upload-label">
<Upload size={20} />
<span>
{selectedFile
? selectedFile.name
: at("media_upload_hint", {}, "Выберите файл или перетащите сюда")}
</span>
<input
type="file"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
on:change={handleFileUpload}
disabled={isUploading}
/>
</label>
{#if isUploading}
<div class="upload-progress">
{at("media_uploading", {}, "Загрузка...")}
</div>
{/if}
{#if broadcastMediaFileId && selectedFile}
<div class="upload-success">
{at("media_uploaded", {}, "Файл загружен")}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Markdown-редактор -->
<Label.Root class="admin-field-label">
<span>
{at("broadcast_label_text", {}, "Текст сообщения")}
<span class="badge-markdown">Markdown</span>
</span>
<small>
{#if broadcastMediaType}
{at("broadcast_hint_caption", {}, "Этот текст будет подписью (caption) к медиа")}
{:else}
{at("broadcast_hint_markdown", {},
"Поддерживается Markdown и HTML-разметка Telegram")}
{/if}
</small>
<!-- Контейнер редактора с превью -->
<div class="markdown-editor-wrapper">
<MarkdownEditor
{carta}
value={broadcastText}
on:change={(e) => broadcastStore.updateField({
broadcastText: e.detail.value
})}
placeholder={at("broadcast_placeholder", {},
"Введите текст сообщения...")}
/>
<!-- Превью отображается автоматически под редактором -->
</div>
</Label.Root>
<!-- Кнопка отправки -->
<div class="actions-row">
<div style="display:flex; gap:8px; align-items:center;">
<AdminButton
variant="primary"
onclick={broadcastStore.runBroadcast}
disabled={!canSend}
disabled={broadcastBusy || !broadcastText.trim()}
>
<Send size={14} />
{broadcastBusy
? at("btn_sending", {}, "Отправка...")
: at("btn_queue", {}, "Поставить в очередь")}
</AdminButton>
{#if broadcastResult}
<span class="admin-muted">
{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} ·
{at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed}
</span>
<span class="admin-muted"
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
"broadcast_stat_failed",
{},
"Неудач"
)}: {broadcastResult.failed}</span
>
{/if}
</div>
</div>
</div>
</div>
<style>
/* --- Стили для медиа-блока --- */
.media-upload-block {
margin: 12px 0;
padding: 12px 16px;
background: var(--bg-subtle, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--border, #e9ecef);
}
.media-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
background: var(--bg-surface, #f1f3f5);
border-radius: 6px;
padding: 4px;
}
.media-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #6c757d);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.media-tab:hover {
background: var(--bg-hover, #e9ecef);
}
.media-tab.active {
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
/* --- Стили для загрузки файлов --- */
.file-upload-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-upload-label {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
border: 2px dashed var(--border, #dee2e6);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary, #6c757d);
justify-content: center;
}
.file-upload-label:hover {
border-color: var(--primary, #0d6efd);
background: var(--bg-subtle, #f8f9fa);
}
.file-upload-label input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.upload-progress {
color: var(--text-secondary, #6c757d);
font-size: 13px;
text-align: center;
}
.upload-success {
color: var(--success, #198754);
font-size: 13px;
text-align: center;
}
/* --- Стили для Markdown-редактора --- */
.markdown-editor-wrapper {
margin-top: 6px;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
overflow: hidden;
}
.markdown-editor-wrapper :global(.carta-editor) {
min-height: 180px;
padding: 12px 16px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
border: none;
outline: none;
resize: vertical;
width: 100%;
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
}
.markdown-editor-wrapper :global(.carta-editor:focus) {
box-shadow: inset 0 0 0 2px var(--primary, #0d6efd);
}
.markdown-editor-wrapper :global(.carta-preview) {
padding: 16px;
border-top: 1px solid var(--border, #dee2e6);
background: var(--bg-subtle, #f8f9fa);
font-size: 14px;
line-height: 1.7;
max-height: 300px;
overflow-y: auto;
}
/* Исправлено: :global(...) перенесён в конец селектора */
.markdown-editor-wrapper :global(.carta-preview h1),
.markdown-editor-wrapper :global(.carta-preview h2),
.markdown-editor-wrapper :global(.carta-preview h3) {
margin: 12px 0 8px;
}
.markdown-editor-wrapper :global(.carta-preview code) {
background: var(--bg-code, #e9ecef);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.markdown-editor-wrapper :global(.carta-preview pre) {
background: var(--bg-code-block, #1e1e1e);
color: #d4d4d4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
}
/* --- Общие стили --- */
.badge-markdown {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: var(--primary-subtle, #e7f1ff);
color: var(--primary, #0d6efd);
margin-left: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.actions-row {
display: flex;
gap: 16px;
align-items: center;
margin-top: 8px;
flex-wrap: wrap;
}
.admin-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
}
.admin-input:focus {
border-color: var(--primary, #0d6efd);
outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.1);
}
</style>
@@ -1,5 +1,3 @@
// frontend/src/lib/admin/stores/broadcastStore.js
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
@@ -12,10 +10,6 @@ export function createBroadcastStore({ api, onToast, at }) {
const state = writable({
broadcastTarget: "all",
broadcastText: "",
// --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
broadcastMediaType: "", // например "photo", "video" и т.д.
broadcastMediaFileId: "", // file_id или URL
// ---------------------------
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: cachedCounts?.counts || null,
@@ -107,46 +101,25 @@ export function createBroadcastStore({ api, onToast, at }) {
}
async function runBroadcast() {
// Собираем текущие значения из стора
let text = "";
let target = "";
let mediaType = "";
let mediaFileId = "";
state.update((s) => {
text = s.broadcastText;
target = s.broadcastTarget;
mediaType = s.broadcastMediaType || "";
mediaFileId = s.broadcastMediaFileId || "";
s.broadcastBusy = true;
s.broadcastResult = null;
return s;
});
try {
// Формируем payload
const payload = {
target,
text: text.trim(),
};
// Добавляем медиа, только если есть и тип, и file_id
if (mediaType && mediaFileId && mediaFileId.trim()) {
payload.media_type = mediaType;
payload.media_file_id = mediaFileId.trim();
}
const res = await api("/admin/broadcast", {
method: "POST",
body: JSON.stringify(payload),
body: JSON.stringify({ target, text }),
});
if (res?.ok) {
state.update((s) => ({
...s,
broadcastText: "",
broadcastMediaType: "",
broadcastMediaFileId: "",
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
}));
onToast(at("broadcast_started", {}, "Рассылка запущена"));
@@ -162,15 +135,6 @@ export function createBroadcastStore({ api, onToast, at }) {
state.update((s) => ({ ...s, ...fields }));
}
// Дополнительный метод для ручного сброса медиа (например, после загрузки файла)
function resetMedia() {
state.update((s) => ({
...s,
broadcastMediaType: "",
broadcastMediaFileId: "",
}));
}
return {
subscribe: state.subscribe,
set: state.set,
@@ -178,7 +142,6 @@ export function createBroadcastStore({ api, onToast, at }) {
runBroadcast,
updateField,
loadCounts,
resetMedia,
BROADCAST_TARGET_OPTIONS,
};
}
}
-1
View File
@@ -37,7 +37,6 @@ export {
LayoutDashboard,
LifeBuoy,
Lock,
Link,
LockKeyhole,
Mail,
Map,
+5 -1
View File
@@ -1,6 +1,8 @@
import re
import shutil
import subprocess
from pathlib import Path
from urllib.parse import urlsplit
import pytest
@@ -33,7 +35,9 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container():
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
assert script.startswith("#!/bin/sh")
assert "raw.githubusercontent.com" in script
raw_url_template = re.search(r"printf '(https://[^']+)' \"\$repo\"", script)
assert raw_url_template is not None
assert urlsplit(raw_url_template.group(1)).hostname == "raw.githubusercontent.com"
assert "git clone" not in script
assert "backend python backend/scripts/import_legacy.py" in script
assert "Optional source Remnashop .env path" in script