diff --git "a/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\236\320\261\321\200\320\260\320\261\320\276\321\202\321\207\320\270\320\272Api.os" "b/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\236\320\261\321\200\320\260\320\261\320\276\321\202\321\207\320\270\320\272Api.os" index bcd3b90..50a823a 100644 --- "a/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\236\320\261\321\200\320\260\320\261\320\276\321\202\321\207\320\270\320\272Api.os" +++ "b/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\236\320\261\321\200\320\260\320\261\320\276\321\202\321\207\320\270\320\272Api.os" @@ -4,6 +4,9 @@ &Пластилин Перем ФабрикаВерсий; +&Пластилин +Перем ПубликацияВерсий; + &Желудь &Прозвище("Обработчик") &Порядок(2) @@ -20,6 +23,8 @@ Если Сегменты[1] = "archive" Тогда ОбработатьАрхив(Контекст, Сегменты); + ИначеЕсли Сегменты[1] = "publish" И Сегменты.Количество() = 2 Тогда // api/publish - вебхук сборки + ПубликацияВерсий.Обработать(Контекст); КонецЕсли; КонецПроцедуры diff --git "a/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\237\321\203\320\261\320\273\320\270\320\272\320\260\321\206\320\270\321\217\320\222\320\265\321\200\321\201\320\270\320\271.os" "b/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\237\321\203\320\261\320\273\320\270\320\272\320\260\321\206\320\270\321\217\320\222\320\265\321\200\321\201\320\270\320\271.os" new file mode 100644 index 0000000..f8755b5 --- /dev/null +++ "b/backend/src/\320\232\320\273\320\260\321\201\321\201\321\213/\320\237\321\203\320\261\320\273\320\270\320\272\320\260\321\206\320\270\321\217\320\222\320\265\321\200\321\201\320\270\320\271.os" @@ -0,0 +1,336 @@ +// Прием вебхука публикации из сборки OneScript на GitHub Actions (release.yml). +// Файлы версии скачиваются из релиза на GitHub и заменяют содержимое каталога канала +// (latest, preview, night-build) и каталога номера версии, а release-notes кладется для страницы версии. +// Отвечаем, когда файлы уже на месте: следом сборка ставит движок с сайта через ovm. + +&Пластилин +Перем КаталогиДанных; + +&Деталька(Значение = "publish.secret", ЗначениеПоУмолчанию = "") +Перем СекретВебхука; + +Перем ДопустимыеКаналы; +Перем БлокировкаЗамены; + +&Желудь +Процедура ПриСозданииОбъекта() + + БлокировкаЗамены = Новый БлокировкаРесурса; + + ДопустимыеКаналы = Новый Соответствие; + ДопустимыеКаналы.Вставить("latest", Истина); + ДопустимыеКаналы.Вставить("preview", Истина); + ДопустимыеКаналы.Вставить("night-build", Истина); + ДопустимыеКаналы.Вставить("lts", Истина); + ДопустимыеКаналы.Вставить("lts-dev", Истина); + +КонецПроцедуры + +Процедура Обработать(Знач Контекст) Экспорт + + Ответ = Контекст.Ответ; + + Если ВРег(Контекст.Запрос.Метод) <> "POST" Тогда + Ответить(Ответ, 405, "Ожидается POST"); + Возврат; + КонецЕсли; + + Если ПустаяСтрока(СекретВебхука) Тогда + Ответить(Ответ, 503, "Публикация не настроена: не задан publish.secret"); + Возврат; + КонецЕсли; + + Тело = ПрочитатьТело(Контекст.Запрос); + Подпись = Строка(Контекст.Запрос.Заголовки.Получить("X-OneScript-Signature-256")); + Если НРег(Подпись) <> "sha256=" + HMACSHA256(Тело) Тогда + Ответить(Ответ, 401, "Неверная подпись"); + Возврат; + КонецЕсли; + + Попытка + Описание = ПрочитатьОписание(Тело); + Исключение + Ответить(Ответ, 400, КраткоеПредставлениеОшибки(ИнформацияОбОшибке())); + Возврат; + КонецПопытки; + + Попытка + Опубликовать(Описание); + Исключение + Ответить(Ответ, 500, ПодробноеПредставлениеОшибки(ИнформацияОбОшибке())); + Возврат; + КонецПопытки; + + Ответить(Ответ, 200, СтрШаблон("Опубликована версия %1 в канал %2", Описание.version, Описание.channel)); + +КонецПроцедуры + +Функция ПрочитатьОписание(Знач Тело) + + Чтение = Новый ЧтениеJSON; + Чтение.УстановитьСтроку(ПолучитьСтрокуИзДвоичныхДанных(Тело, "UTF-8")); + Описание = ПрочитатьJSON(Чтение); + Чтение.Закрыть(); + + Для Каждого Поле Из СтрРазделить("channel,version,files", ",") Цикл + Если Не Описание.Свойство(Поле) Тогда + ВызватьИсключение "Нет поля " + Поле; + КонецЕсли; + КонецЦикла; + Для Каждого Поле Из СтрРазделить("versionDir,releaseNotes", ",") Цикл + Если Не Описание.Свойство(Поле) Тогда + Описание.Вставить(Поле, Неопределено); + КонецЕсли; + КонецЦикла; + + Если ДопустимыеКаналы[Описание.channel] = Неопределено Тогда + ВызватьИсключение "Неизвестный канал: " + Описание.channel; + КонецЕсли; + + Если Описание.versionDir <> Неопределено Тогда + ПроверитьИмя(Описание.versionDir); + Если ДопустимыеКаналы[Описание.versionDir] <> Неопределено Тогда + ВызватьИсключение "Каталог версии совпадает с каналом: " + Описание.versionDir; + КонецЕсли; + КонецЕсли; + + Если Не ЗначениеЗаполнено(Описание.files) Тогда + ВызватьИсключение "Нет файлов для публикации"; + КонецЕсли; + Для Каждого ОписаниеФайла Из Описание.files Цикл + ПроверитьИмя(ОписаниеФайла.name); + КонецЦикла; + + Возврат Описание; + +КонецФункции + +// Имя файла или каталога без путей: из описания нельзя выйти за пределы каталога версий +Процедура ПроверитьИмя(Знач Имя) + Если ТипЗнч(Имя) <> Тип("Строка") Или ПустаяСтрока(Имя) + Или СтрНайти(Имя, "/") Или СтрНайти(Имя, "\") Или СтрНачинаетсяС(Имя, ".") Тогда + ВызватьИсключение "Недопустимое имя: " + Имя; + КонецЕсли; +КонецПроцедуры + +Процедура Опубликовать(Знач Описание) + + КаталогВходящих = ОбъединитьПути(КаталогиДанных.КаталогЗагрузок(), "incoming", Строка(Новый УникальныйИдентификатор)); + СоздатьКаталог(КаталогВходящих); + + Попытка + КаталогКанала = ОбъединитьПути(КаталогВходящих, Описание.channel); + СоздатьКаталог(КаталогКанала); + Для Каждого ОписаниеФайла Из Описание.files Цикл + СкачатьФайл(ОписаниеФайла, ОбъединитьПути(КаталогКанала, ОписаниеФайла.name)); + КонецЦикла; + + // Копия для каталога номера версии готовится до замены: сама замена - только перенос файлов + Если Описание.versionDir <> Неопределено Тогда + КаталогНомера = ОбъединитьПути(КаталогВходящих, Описание.versionDir); + СоздатьКаталог(КаталогНомера); + Для Каждого ОписаниеФайла Из Описание.files Цикл + КопироватьФайл(ОбъединитьПути(КаталогКанала, ОписаниеФайла.name), ОбъединитьПути(КаталогНомера, ОписаниеФайла.name)); + КонецЦикла; + КонецЕсли; + + // Имя с точкой не совпадет с каналом или номером версии: такие имена ПроверитьИмя не пропускает + Если Описание.releaseNotes <> Неопределено Тогда + КаталогОписания = ОбъединитьПути(КаталогВходящих, ".notes"); + СоздатьКаталог(КаталогОписания); + ФайлОписания = ОбъединитьПути(КаталогОписания, Описание.channel + ".md"); + Скачать(Описание.releaseNotes, ФайлОписания); + Если Описание.versionDir <> Неопределено Тогда + КопироватьФайл(ФайлОписания, ОбъединитьПути(КаталогОписания, Описание.versionDir + ".md")); + КонецЕсли; + КонецЕсли; + + КаталогВерсий = КаталогиДанных.КаталогВерсий(); + Замены = Новый Массив; + Замены.Добавить(ОписаниеЗамены(КаталогКанала, ОбъединитьПути(КаталогВерсий, Описание.channel))); + Если Описание.versionDir <> Неопределено Тогда + Замены.Добавить(ОписаниеЗамены(КаталогНомера, ОбъединитьПути(КаталогВерсий, Описание.versionDir))); + КонецЕсли; + Если Описание.releaseNotes <> Неопределено Тогда + // Описания всех версий лежат в одном каталоге, заменяются только свои + Замены.Добавить(ОписаниеЗамены(КаталогОписания, ОбъединитьПути(КаталогиДанных.КаталогДокументации(), "versions"), Ложь)); + КонецЕсли; + + // Скачивание идет параллельно, а замена - по одной, чтобы публикации не перемешали файлы + БлокировкаЗамены.Заблокировать(); + Попытка + ЗаменитьФайлы(Замены, КаталогВходящих); + Исключение + БлокировкаЗамены.Разблокировать(); + ВызватьИсключение; + КонецПопытки; + БлокировкаЗамены.Разблокировать(); + Исключение + УдалитьФайлы(КаталогВходящих); + ВызватьИсключение; + КонецПопытки; + + УдалитьФайлы(КаталогВходящих); + +КонецПроцедуры + +Процедура СкачатьФайл(Знач ОписаниеФайла, Знач ПутьФайла) + + Скачать(ОписаниеФайла.url, ПутьФайла); + + Файл = Новый Файл(ПутьФайла); + Если Файл.Размер() <> ОписаниеФайла.size Тогда + ВызватьИсключение СтрШаблон("Размер %1 не совпал: %2 вместо %3", ОписаниеФайла.name, Файл.Размер(), ОписаниеФайла.size); + КонецЕсли; + + Хеш = Новый ХешированиеДанных(ХешФункция.SHA256); + Хеш.ДобавитьФайл(ПутьФайла); + Если НРег(ПолучитьHexСтрокуИзДвоичныхДанных(Хеш.ХешСумма)) <> НРег(ОписаниеФайла.sha256) Тогда + ВызватьИсключение "Не совпала контрольная сумма " + ОписаниеФайла.name; + КонецЕсли; + +КонецПроцедуры + +// Файлы берутся только с GitHub: из релиза и release-notes из репозитория +Процедура Скачать(Знач Адрес, Знач ПутьФайла) + + Сервер = Неопределено; + Для Каждого Допустимый Из СтрРазделить("https://github.com/,https://raw.githubusercontent.com/", ",") Цикл + Если СтрНачинаетсяС(Адрес, Допустимый) Тогда + Сервер = Лев(Допустимый, СтрДлина(Допустимый) - 1); + КонецЕсли; + КонецЦикла; + Если Сервер = Неопределено Тогда + ВызватьИсключение "Недопустимый адрес файла: " + Адрес; + КонецЕсли; + + Соединение = Новый HTTPСоединение(Сервер, , , , , 600); + Ответ = Соединение.Получить(Новый HTTPЗапрос(Сред(Адрес, СтрДлина(Сервер) + 1)), ПутьФайла); + Если Ответ.КодСостояния <> 200 Тогда + ВызватьИсключение СтрШаблон("Не удалось скачать %1: код %2", Адрес, Ответ.КодСостояния); + КонецЕсли; + +КонецПроцедуры + +Функция ОписаниеЗамены(Знач Источник, Знач Цель, Знач ВсеФайлы = Истина) + Замена = Новый Структура; + Замена.Вставить("Источник", Источник); + Замена.Вставить("Цель", Цель); + Замена.Вставить("ВсеФайлы", ВсеФайлы); + Замена.Вставить("Резерв"); + Замена.Вставить("НовыйКаталог", Ложь); + Замена.Вставить("Перенесенные", Новый Массив); + Возврат Замена; +КонецФункции + +// Как rsync --delete: в целевых каталогах остаются ровно новые файлы, а без ВсеФайлы заменяются только +// одноименные. Каталог целиком не переименовать, поэтому старые файлы сначала уходят в резерв на той же +// файловой системе, а при ошибке новые убираются и старые возвращаются на место во всех каталогах +Процедура ЗаменитьФайлы(Знач Замены, Знач КаталогВходящих) + + Начатые = Новый Массив; + Попытка + Для Каждого Замена Из Замены Цикл + Начатые.Добавить(Замена); + Замена.Резерв = ОбъединитьПути(КаталогВходящих, ".previous-" + Начатые.Количество()); + ЦелевойКаталог = Новый Файл(Замена.Цель); + Замена.НовыйКаталог = Не ЦелевойКаталог.Существует(); + СоздатьКаталог(Замена.Цель); + СоздатьКаталог(Замена.Резерв); + Если Замена.ВсеФайлы Тогда + ПеренестиФайлы(Замена.Цель, Замена.Резерв); + Иначе + Для Каждого Файл Из НайтиФайлы(Замена.Источник, ПолучитьМаскуВсеФайлы()) Цикл + Старый = Новый Файл(ОбъединитьПути(Замена.Цель, Файл.Имя)); + Если Старый.Существует() Тогда + ПереместитьФайл(Старый.ПолноеИмя, ОбъединитьПути(Замена.Резерв, Файл.Имя)); + КонецЕсли; + КонецЦикла; + КонецЕсли; + ПеренестиФайлы(Замена.Источник, Замена.Цель, Замена.Перенесенные); + КонецЦикла; + Исключение + Для Каждого Замена Из Начатые Цикл + Для Каждого ИмяФайла Из Замена.Перенесенные Цикл + УдалитьФайлы(ОбъединитьПути(Замена.Цель, ИмяФайла)); + КонецЦикла; + ПеренестиФайлы(Замена.Резерв, Замена.Цель); + // Пустой каталог новой версии иначе попал бы в архив + Если Замена.НовыйКаталог Тогда + УдалитьФайлы(Замена.Цель); + КонецЕсли; + КонецЦикла; + ВызватьИсключение; + КонецПопытки; + +КонецПроцедуры + +Процедура ПеренестиФайлы(Знач Откуда, Знач Куда, Знач Перенесенные = Неопределено) + Для Каждого Файл Из НайтиФайлы(Откуда, ПолучитьМаскуВсеФайлы()) Цикл + ПереместитьФайл(Файл.ПолноеИмя, ОбъединитьПути(Куда, Файл.Имя)); + Если Перенесенные <> Неопределено Тогда + Перенесенные.Добавить(Файл.Имя); + КонецЕсли; + КонецЦикла; +КонецПроцедуры + +Функция ПрочитатьТело(Знач Запрос) + Поток = Новый ПотокВПамяти; + Запрос.Тело.КопироватьВ(Поток); + Возврат Поток.ЗакрытьИПолучитьДвоичныеДанные(); +КонецФункции + +// HMAC-SHA256 тела на секрете вебхука, шестнадцатеричной строкой +Функция HMACSHA256(Знач Данные) + + РазмерБлока = 64; + Ключ = ПолучитьДвоичныеДанныеИзСтроки(СекретВебхука, "UTF-8"); + Если Ключ.Размер() > РазмерБлока Тогда + Ключ = SHA256(Ключ); + КонецЕсли; + + БуферКлюча = Новый БуферДвоичныхДанных(РазмерБлока); + БуферКлюча.Записать(0, ПолучитьБуферДвоичныхДанныхИзДвоичныхДанных(Ключ)); + + ВнутреннийКлюч = БуферКлюча.Скопировать(); + ВнутреннийКлюч.ЗаписатьПобитовоеИсключительноеИли(0, ЗаполненныйБуфер(РазмерБлока, 54)); // 0x36 + ВнешнийКлюч = БуферКлюча.Скопировать(); + ВнешнийКлюч.ЗаписатьПобитовоеИсключительноеИли(0, ЗаполненныйБуфер(РазмерБлока, 92)); // 0x5c + + Внутренний = SHA256(Склеить(ДвоичныеИзБуфера(ВнутреннийКлюч), Данные)); + Внешний = SHA256(Склеить(ДвоичныеИзБуфера(ВнешнийКлюч), Внутренний)); + + Возврат НРег(ПолучитьHexСтрокуИзДвоичныхДанных(Внешний)); + +КонецФункции + +Функция SHA256(Знач Данные) + Хеш = Новый ХешированиеДанных(ХешФункция.SHA256); + Хеш.Добавить(Данные); + Возврат Хеш.ХешСумма; +КонецФункции + +Функция ЗаполненныйБуфер(Знач Размер, Знач Байт) + Буфер = Новый БуферДвоичныхДанных(Размер); + Для Сч = 0 По Размер - 1 Цикл + Буфер.Установить(Сч, Байт); + КонецЦикла; + Возврат Буфер; +КонецФункции + +Функция Склеить(Знач Первые, Знач Вторые) + Части = Новый Массив; + Части.Добавить(Первые); + Части.Добавить(Вторые); + Возврат СоединитьДвоичныеДанные(Части); +КонецФункции + +Функция ДвоичныеИзБуфера(Знач Буфер) + Возврат ПолучитьДвоичныеДанныеИзБуфераДвоичныхДанных(Буфер); +КонецФункции + +Процедура Ответить(Знач Ответ, Знач Код, Знач Текст) + Ответ.КодСостояния = Код; + Ответ.ТипКонтента = "text/plain; charset=utf-8"; + Ответ.Записать(Текст); +КонецПроцедуры