rin
Публичный Open-source lightweight git hosting.

feat(releases): upload, download and delete release asset files

R rin · 2026-10-16 17:31 UTC
8 файлов изменено +1033 -240
internal/db/migrations/005_release_assets.sql
+14 -0
... ... @@ -0,0 +1,14 @@
1 +-- Uploaded release assets (binaries, archives, checksums).
2 +CREATE TABLE IF NOT EXISTS release_assets (
3 + id INTEGER PRIMARY KEY AUTOINCREMENT,
4 + release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
5 + uploader_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 + filename TEXT NOT NULL,
7 + stored_name TEXT NOT NULL,
8 + size INTEGER NOT NULL DEFAULT 0,
9 + content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
10 + download_count INTEGER NOT NULL DEFAULT 0,
11 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
12 +);
13 +
14 +CREATE INDEX IF NOT EXISTS idx_release_assets_release ON release_assets(release_id, created_at ASC);
internal/i18n/i18n.go
+258 -230
... ... @@ -191,35 +191,39 @@ var translations = map[Lang]map[string]string{
191 191 "pulls.wants_to_merge": "хочет слить",
192 192
193 193 // Releases
194 - "releases.title": "Релизы",
195 - "releases.new": "Новый релиз",
196 - "releases.empty": "Релизов пока нет",
197 - "releases.tag_select": "Тег",
198 - "releases.title_ph": "Название релиза",
199 - "releases.desc_ph": "Описание релиза, список изменений (Markdown)...",
200 - "releases.prerelease": "Пре-релиз (нестабильная сборка)",
201 - "releases.publish": "Опубликовать релиз",
202 - "releases.delete": "Удалить релиз",
203 - "releases.assets": "Файлы",
204 - "releases.source_zip": "Исходный код (zip)",
205 - "releases.source_tar": "Исходный код (tar.gz)",
206 - "releases.body": "Описание",
207 - "releases.body_ph": "Опишите релиз, список изменений (Markdown)...",
208 - "releases.count": "релизов",
209 - "releases.count_one": "релиз",
210 - "releases.count_few": "релиза",
211 - "releases.count_many": "релизов",
212 - "releases.delete_confirm": "Удалить этот релиз? Действие необратимо.",
213 - "releases.is_prerelease": "Пре-релиз (нестабильная сборка)",
214 - "releases.publish_btn": "Опубликовать релиз",
215 - "releases.tag_desc": "Существующий тег или новый — он будет создан автоматически.",
216 - "releases.tag_name": "Тег",
217 - "releases.tags": "тегов",
218 - "releases.tags_one": "тег",
219 - "releases.tags_few": "тега",
220 - "releases.tags_many": "тегов",
221 - "releases.target": "Ветка",
222 - "releases.target_desc": "Ветка или коммит, на который будет указывать тег.",
194 + "releases.title": "Релизы",
195 + "releases.new": "Новый релиз",
196 + "releases.assets_upload": "Файлы релиза",
197 + "releases.assets_upload_hint": "Необязательно. До 20 файлов, каждый до 100 МБ.",
198 + "releases.asset_delete": "Удалить файл",
199 + "releases.asset_delete_confirm": "Удалить этот файл? Действие необратимо.",
200 + "releases.empty": "Релизов пока нет",
201 + "releases.tag_select": "Тег",
202 + "releases.title_ph": "Название релиза",
203 + "releases.desc_ph": "Описание релиза, список изменений (Markdown)...",
204 + "releases.prerelease": "Пре-релиз (нестабильная сборка)",
205 + "releases.publish": "Опубликовать релиз",
206 + "releases.delete": "Удалить релиз",
207 + "releases.assets": "Файлы",
208 + "releases.source_zip": "Исходный код (zip)",
209 + "releases.source_tar": "Исходный код (tar.gz)",
210 + "releases.body": "Описание",
211 + "releases.body_ph": "Опишите релиз, список изменений (Markdown)...",
212 + "releases.count": "релизов",
213 + "releases.count_one": "релиз",
214 + "releases.count_few": "релиза",
215 + "releases.count_many": "релизов",
216 + "releases.delete_confirm": "Удалить этот релиз? Действие необратимо.",
217 + "releases.is_prerelease": "Пре-релиз (нестабильная сборка)",
218 + "releases.publish_btn": "Опубликовать релиз",
219 + "releases.tag_desc": "Существующий тег или новый — он будет создан автоматически.",
220 + "releases.tag_name": "Тег",
221 + "releases.tags": "тегов",
222 + "releases.tags_one": "тег",
223 + "releases.tags_few": "тега",
224 + "releases.tags_many": "тегов",
225 + "releases.target": "Ветка",
226 + "releases.target_desc": "Ветка или коммит, на который будет указывать тег.",
223 227
224 228 // Activity
225 229 "activity.title": "Активность",
... ... @@ -329,56 +333,62 @@ var translations = map[Lang]map[string]string{
329 333 "nav.toggle_sidebar": "Сайдбар (Ctrl+B)",
330 334
331 335 // Флеш-сообщения
332 - "flash.welcome": "Добро пожаловать в Polar, @%s!",
333 - "flash.profile_updated": "Профиль обновлён.",
334 - "flash.password_updated": "Пароль успешно изменён.",
335 - "flash.token_revoked": "Токен доступа отозван.",
336 - "flash.settings_updated": "Настройки сохранены.",
337 - "flash.error": "Ошибка: %s",
338 - "flash.comment_error": "Не удалось отправить комментарий: %s",
339 - "flash.pr_created": "Пул-реквест #%d создан.",
340 - "flash.pr_closed": "Пул-реквест #%d закрыт.",
341 - "flash.pr_reopened": "Пул-реквест #%d открыт заново.",
342 - "flash.pr_merged": "Пул-реквест #%d слит.",
343 - "flash.pr_merged_state": "Нельзя изменить состояние слитого пул-реквеста.",
344 - "flash.pr_merge_open_only": "Слить можно только открытый пул-реквест.",
345 - "flash.pr_merge_failed": "Не удалось слить: %s",
346 - "flash.release_tag_empty": "Имя тега не может быть пустым.",
347 - "flash.release_tag_failed": "Не удалось создать тег: %s",
348 - "flash.release_create_failed": "Не удалось создать релиз: %s",
349 - "flash.release_published": "Релиз %s опубликован.",
350 - "flash.release_delete_failed": "Не удалось удалить релиз: %s",
351 - "flash.release_deleted": "Релиз удалён.",
352 - "file.new_file": "Новый файл",
353 - "file.new_folder": "Новая папка",
354 - "file.path": "Путь",
355 - "file.path_ph": "напр. src/main.go",
356 - "file.folder_ph": "напр. docs",
357 - "file.branch": "Ветка",
358 - "file.content": "Содержимое",
359 - "file.content_ph": "Текст файла…",
360 - "file.message": "Сообщение коммита",
361 - "file.create_file": "Создать файл",
362 - "file.create_folder": "Создать папку",
363 - "file.folder_hint": "Git не хранит пустые папки — Polar добавит туда .gitkeep.",
364 - "flash.file_created": "Файл создан.",
365 - "flash.folder_created": "Папка создана.",
366 - "flash.file_exists": "Файл с таким путём уже существует.",
367 - "flash.path_invalid": "Некорректный путь.",
368 - "flash.file_create_failed": "Не удалось создать файл.",
369 - "flash.folder_create_failed": "Не удалось создать папку.",
370 - "file.upload_file": "Загрузить файлы",
371 - "file.upload_dest": "Папка назначения",
372 - "file.upload_dest_ph": "напр. docs/images (необязательно)",
373 - "file.upload_pick": "Выберите файлы или перетащите сюда",
374 - "file.upload_hint": "До 25 файлов, до 50 МБ суммарно.",
375 - "file.upload_submit": "Загрузить",
376 - "file.upload_replace_hint": "Существующие файлы будут перезаписаны.",
377 - "flash.upload_done": "Загружено файлов: %d.",
378 - "flash.upload_none": "Файлы не выбраны.",
379 - "flash.upload_failed": "Не удалось загрузить файлы.",
380 - "flash.upload_too_big": "Слишком большой объём — лимит 50 МБ.",
381 - "flash.upload_too_many": "Слишком много файлов: максимум %d.", "flash.repo_settings_saved": "Настройки репозитория сохранены.",
336 + "flash.welcome": "Добро пожаловать в Polar, @%s!",
337 + "flash.profile_updated": "Профиль обновлён.",
338 + "flash.password_updated": "Пароль успешно изменён.",
339 + "flash.token_revoked": "Токен доступа отозван.",
340 + "flash.settings_updated": "Настройки сохранены.",
341 + "flash.error": "Ошибка: %s",
342 + "flash.comment_error": "Не удалось отправить комментарий: %s",
343 + "flash.pr_created": "Пул-реквест #%d создан.",
344 + "flash.pr_closed": "Пул-реквест #%d закрыт.",
345 + "flash.pr_reopened": "Пул-реквест #%d открыт заново.",
346 + "flash.pr_merged": "Пул-реквест #%d слит.",
347 + "flash.pr_merged_state": "Нельзя изменить состояние слитого пул-реквеста.",
348 + "flash.pr_merge_open_only": "Слить можно только открытый пул-реквест.",
349 + "flash.pr_merge_failed": "Не удалось слить: %s",
350 + "flash.release_tag_empty": "Имя тега не может быть пустым.",
351 + "flash.release_tag_failed": "Не удалось создать тег: %s",
352 + "flash.release_create_failed": "Не удалось создать релиз: %s",
353 + "flash.release_published": "Релиз %s опубликован.",
354 + "flash.release_delete_failed": "Не удалось удалить релиз: %s",
355 + "flash.release_deleted": "Релиз удалён.",
356 + "flash.release_published_with_assets": "Релиз %s опубликован, файлов прикреплено: %d.",
357 + "flash.release_published_assets_failed": "Релиз %s опубликован, но часть файлов не удалось сохранить.",
358 + "flash.release_asset_too_many": "Слишком много файлов: максимум %d.",
359 + "flash.release_asset_too_big": "Файл слишком большой — лимит 100 МБ на файл.",
360 + "flash.release_asset_deleted": "Файл удалён.",
361 + "flash.release_asset_delete_failed": "Не удалось удалить файл: %s",
362 + "file.new_file": "Новый файл",
363 + "file.new_folder": "Новая папка",
364 + "file.path": "Путь",
365 + "file.path_ph": "напр. src/main.go",
366 + "file.folder_ph": "напр. docs",
367 + "file.branch": "Ветка",
368 + "file.content": "Содержимое",
369 + "file.content_ph": "Текст файла…",
370 + "file.message": "Сообщение коммита",
371 + "file.create_file": "Создать файл",
372 + "file.create_folder": "Создать папку",
373 + "file.folder_hint": "Git не хранит пустые папки — Polar добавит туда .gitkeep.",
374 + "flash.file_created": "Файл создан.",
375 + "flash.folder_created": "Папка создана.",
376 + "flash.file_exists": "Файл с таким путём уже существует.",
377 + "flash.path_invalid": "Некорректный путь.",
378 + "flash.file_create_failed": "Не удалось создать файл.",
379 + "flash.folder_create_failed": "Не удалось создать папку.",
380 + "file.upload_file": "Загрузить файлы",
381 + "file.upload_dest": "Папка назначения",
382 + "file.upload_dest_ph": "напр. docs/images (необязательно)",
383 + "file.upload_pick": "Выберите файлы или перетащите сюда",
384 + "file.upload_hint": "До 25 файлов, до 50 МБ суммарно.",
385 + "file.upload_submit": "Загрузить",
386 + "file.upload_replace_hint": "Существующие файлы будут перезаписаны.",
387 + "flash.upload_done": "Загружено файлов: %d.",
388 + "flash.upload_none": "Файлы не выбраны.",
389 + "flash.upload_failed": "Не удалось загрузить файлы.",
390 + "flash.upload_too_big": "Слишком большой объём — лимит 50 МБ.",
391 + "flash.upload_too_many": "Слишком много файлов: максимум %d.", "flash.repo_settings_saved": "Настройки репозитория сохранены.",
382 392 "flash.repo_rename_failed": "Не удалось переименовать: %s",
383 393 "flash.repo_renamed": "Репозиторий переименован в %s.",
384 394 "flash.repo_confirm_mismatch": "Введённое имя не совпадает с названием репозитория.",
... ... @@ -396,42 +406,46 @@ var translations = map[Lang]map[string]string{
396 406 "flash.issue_reopened": "Задача #%d открыта заново.",
397 407
398 408 // Ошибочные страницы
399 - "err.not_found": "Не найдено",
400 - "err.forbidden": "Доступ запрещён",
401 - "err.session_error": "Ошибка сессии",
402 - "err.session_create": "Не удалось создать сессию.",
403 - "err.settings_error": "Ошибка настроек",
404 - "err.settings_save": "Не удалось сохранить настройку.",
405 - "err.explore_disabled": "Раздел «Обзор» отключён на этом сервере.",
406 - "err.activity_disabled": "Активность отключена для этого репозитория.",
407 - "err.pulls_disabled": "Пул-реквесты отключены для этого репозитория.",
408 - "err.releases_disabled": "Релизы отключены для этого репозитория.",
409 - "err.issues_disabled": "Задачи отключены для этого репозитория.",
410 - "err.repo_not_found": "Репозиторий не найден",
411 - "err.repo_not_found_desc": "Репозиторий не существует или у вас нет к нему доступа.",
412 - "err.repo_private_desc": "Репозиторий не существует или является приватным.",
413 - "err.access_denied": "Доступ запрещён",
414 - "err.repo_write_denied": "У вас нет прав на изменение этого репозитория.",
415 - "err.invalid_repo_path": "Некорректный путь репозитория",
416 - "err.path_not_found": "Путь не найден",
417 - "err.path_not_found_desc": "Не удалось найти путь в репозитории для ссылки %s",
418 - "err.file_not_found": "Файл не найден",
419 - "err.file_not_found_desc": "Запрошенный файл не найден в %s",
420 - "err.commit_not_found": "Коммит не найден",
421 - "err.commit_not_found_desc": "Не удалось найти коммит %s",
422 - "err.pr_not_found": "Пул-реквест не найден",
423 - "err.pr_not_found_desc": "Не удалось найти пул-реквест.",
424 - "err.pr_state_forbidden": "Вы не можете изменить состояние этого пул-реквеста.",
425 - "err.pr_merge_forbidden": "У вас нет прав на слияние пул-реквестов.",
426 - "err.issue_not_found": "Задача не найдена",
427 - "err.issue_not_found_desc": "Не удалось найти задачу.",
428 - "err.issue_state_forbidden": "Вы не можете изменить состояние этой задачи.",
429 - "err.delete_error": "Ошибка удаления",
430 - "err.repo_delete_failed": "Не удалось удалить репозиторий.",
431 - "err.user_not_found": "Пользователь @%s не существует.",
432 - "err.archive_error": "Ошибка архива",
433 - "err.archive_tar_failed": "Не удалось создать tar.gz архив для %s",
434 - "err.archive_zip_failed": "Не удалось создать zip архив для %s",
409 + "err.not_found": "Не найдено",
410 + "err.forbidden": "Доступ запрещён",
411 + "err.session_error": "Ошибка сессии",
412 + "err.session_create": "Не удалось создать сессию.",
413 + "err.settings_error": "Ошибка настроек",
414 + "err.settings_save": "Не удалось сохранить настройку.",
415 + "err.explore_disabled": "Раздел «Обзор» отключён на этом сервере.",
416 + "err.activity_disabled": "Активность отключена для этого репозитория.",
417 + "err.pulls_disabled": "Пул-реквесты отключены для этого репозитория.",
418 + "err.releases_disabled": "Релизы отключены для этого репозитория.",
419 + "err.release_not_found": "Релиз не найден",
420 + "err.release_not_found_desc": "Не удалось найти релиз.",
421 + "err.asset_not_found": "Файл не найден",
422 + "err.asset_not_found_desc": "Запрошенный файл релиза не найден.",
423 + "err.issues_disabled": "Задачи отключены для этого репозитория.",
424 + "err.repo_not_found": "Репозиторий не найден",
425 + "err.repo_not_found_desc": "Репозиторий не существует или у вас нет к нему доступа.",
426 + "err.repo_private_desc": "Репозиторий не существует или является приватным.",
427 + "err.access_denied": "Доступ запрещён",
428 + "err.repo_write_denied": "У вас нет прав на изменение этого репозитория.",
429 + "err.invalid_repo_path": "Некорректный путь репозитория",
430 + "err.path_not_found": "Путь не найден",
431 + "err.path_not_found_desc": "Не удалось найти путь в репозитории для ссылки %s",
432 + "err.file_not_found": "Файл не найден",
433 + "err.file_not_found_desc": "Запрошенный файл не найден в %s",
434 + "err.commit_not_found": "Коммит не найден",
435 + "err.commit_not_found_desc": "Не удалось найти коммит %s",
436 + "err.pr_not_found": "Пул-реквест не найден",
437 + "err.pr_not_found_desc": "Не удалось найти пул-реквест.",
438 + "err.pr_state_forbidden": "Вы не можете изменить состояние этого пул-реквеста.",
439 + "err.pr_merge_forbidden": "У вас нет прав на слияние пул-реквестов.",
440 + "err.issue_not_found": "Задача не найдена",
441 + "err.issue_not_found_desc": "Не удалось найти задачу.",
442 + "err.issue_state_forbidden": "Вы не можете изменить состояние этой задачи.",
443 + "err.delete_error": "Ошибка удаления",
444 + "err.repo_delete_failed": "Не удалось удалить репозиторий.",
445 + "err.user_not_found": "Пользователь @%s не существует.",
446 + "err.archive_error": "Ошибка архива",
447 + "err.archive_tar_failed": "Не удалось создать tar.gz архив для %s",
448 + "err.archive_zip_failed": "Не удалось создать zip архив для %s",
435 449
436 450 // Форки
437 451 "fork.forked_from": "форк от",
... ... @@ -684,35 +698,39 @@ var translations = map[Lang]map[string]string{
684 698 "pulls.wants_to_merge": "wants to merge",
685 699
686 700 // Releases
687 - "releases.title": "Releases",
688 - "releases.new": "Draft a new release",
689 - "releases.empty": "No releases yet",
690 - "releases.tag_select": "Tag",
691 - "releases.title_ph": "Release title",
692 - "releases.desc_ph": "Describe this release, release notes (Markdown)...",
693 - "releases.prerelease": "This is a pre-release",
694 - "releases.publish": "Publish release",
695 - "releases.delete": "Delete release",
696 - "releases.assets": "Assets",
697 - "releases.source_zip": "Source code (zip)",
698 - "releases.source_tar": "Source code (tar.gz)",
699 - "releases.body": "Description",
700 - "releases.body_ph": "Describe this release, release notes (Markdown)...",
701 - "releases.count": "releases",
702 - "releases.count_one": "release",
703 - "releases.count_few": "releases",
704 - "releases.count_many": "releases",
705 - "releases.delete_confirm": "Delete this release? This cannot be undone.",
706 - "releases.is_prerelease": "This is a pre-release",
707 - "releases.publish_btn": "Publish release",
708 - "releases.tag_desc": "An existing tag or a new one — it will be created automatically.",
709 - "releases.tag_name": "Tag",
710 - "releases.tags": "tags",
711 - "releases.tags_one": "tag",
712 - "releases.tags_few": "tags",
713 - "releases.tags_many": "tags",
714 - "releases.target": "Branch",
715 - "releases.target_desc": "Branch or commit the tag will point to.",
701 + "releases.title": "Releases",
702 + "releases.new": "Draft a new release",
703 + "releases.assets_upload": "Release files",
704 + "releases.assets_upload_hint": "Optional. Up to 20 files, 100 MB each.",
705 + "releases.asset_delete": "Delete file",
706 + "releases.asset_delete_confirm": "Delete this file? This cannot be undone.",
707 + "releases.empty": "No releases yet",
708 + "releases.tag_select": "Tag",
709 + "releases.title_ph": "Release title",
710 + "releases.desc_ph": "Describe this release, release notes (Markdown)...",
711 + "releases.prerelease": "This is a pre-release",
712 + "releases.publish": "Publish release",
713 + "releases.delete": "Delete release",
714 + "releases.assets": "Assets",
715 + "releases.source_zip": "Source code (zip)",
716 + "releases.source_tar": "Source code (tar.gz)",
717 + "releases.body": "Description",
718 + "releases.body_ph": "Describe this release, release notes (Markdown)...",
719 + "releases.count": "releases",
720 + "releases.count_one": "release",
721 + "releases.count_few": "releases",
722 + "releases.count_many": "releases",
723 + "releases.delete_confirm": "Delete this release? This cannot be undone.",
724 + "releases.is_prerelease": "This is a pre-release",
725 + "releases.publish_btn": "Publish release",
726 + "releases.tag_desc": "An existing tag or a new one — it will be created automatically.",
727 + "releases.tag_name": "Tag",
728 + "releases.tags": "tags",
729 + "releases.tags_one": "tag",
730 + "releases.tags_few": "tags",
731 + "releases.tags_many": "tags",
732 + "releases.target": "Branch",
733 + "releases.target_desc": "Branch or commit the tag will point to.",
716 734
717 735 // Activity
718 736 "activity.title": "Activity",
... ... @@ -822,56 +840,62 @@ var translations = map[Lang]map[string]string{
822 840 "nav.toggle_sidebar": "Sidebar (Ctrl+B)",
823 841
824 842 // Flash messages
825 - "flash.welcome": "Welcome to Polar, @%s!",
826 - "flash.profile_updated": "Profile updated.",
827 - "flash.password_updated": "Password changed successfully.",
828 - "flash.token_revoked": "Access token revoked.",
829 - "flash.settings_updated": "Settings saved.",
830 - "flash.error": "Error: %s",
831 - "flash.comment_error": "Could not post the comment: %s",
832 - "flash.pr_created": "Pull request #%d created.",
833 - "flash.pr_closed": "Pull request #%d closed.",
834 - "flash.pr_reopened": "Pull request #%d reopened.",
835 - "flash.pr_merged": "Pull request #%d merged.",
836 - "flash.pr_merged_state": "Cannot change the state of a merged pull request.",
837 - "flash.pr_merge_open_only": "Only an open pull request can be merged.",
838 - "flash.pr_merge_failed": "Could not merge: %s",
839 - "flash.release_tag_empty": "Tag name cannot be empty.",
840 - "flash.release_tag_failed": "Could not create the tag: %s",
841 - "flash.release_create_failed": "Could not create the release: %s",
842 - "flash.release_published": "Release %s published.",
843 - "flash.release_delete_failed": "Could not delete the release: %s",
844 - "flash.release_deleted": "Release deleted.",
845 - "file.new_file": "New file",
846 - "file.new_folder": "New folder",
847 - "file.path": "Path",
848 - "file.path_ph": "e.g. src/main.go",
849 - "file.folder_ph": "e.g. docs",
850 - "file.branch": "Branch",
851 - "file.content": "Content",
852 - "file.content_ph": "File contents…",
853 - "file.message": "Commit message",
854 - "file.create_file": "Create file",
855 - "file.create_folder": "Create folder",
856 - "file.folder_hint": "Git does not track empty folders — Polar adds a .gitkeep placeholder.",
857 - "flash.file_created": "File created.",
858 - "flash.folder_created": "Folder created.",
859 - "flash.file_exists": "A file with that path already exists.",
860 - "flash.path_invalid": "Invalid path.",
861 - "flash.file_create_failed": "Could not create the file.",
862 - "flash.folder_create_failed": "Could not create the folder.",
863 - "file.upload_file": "Upload files",
864 - "file.upload_dest": "Destination folder",
865 - "file.upload_dest_ph": "e.g. docs/images (optional)",
866 - "file.upload_pick": "Choose files or drag them here",
867 - "file.upload_hint": "Up to 25 files, 50 MB in total.",
868 - "file.upload_submit": "Upload",
869 - "file.upload_replace_hint": "Existing files will be overwritten.",
870 - "flash.upload_done": "Uploaded %d file(s).",
871 - "flash.upload_none": "No files selected.",
872 - "flash.upload_failed": "Could not upload the files.",
873 - "flash.upload_too_big": "Upload is too large — 50 MB limit.",
874 - "flash.upload_too_many": "Too many files: %d max.", "flash.repo_settings_saved": "Repository settings saved.",
843 + "flash.welcome": "Welcome to Polar, @%s!",
844 + "flash.profile_updated": "Profile updated.",
845 + "flash.password_updated": "Password changed successfully.",
846 + "flash.token_revoked": "Access token revoked.",
847 + "flash.settings_updated": "Settings saved.",
848 + "flash.error": "Error: %s",
849 + "flash.comment_error": "Could not post the comment: %s",
850 + "flash.pr_created": "Pull request #%d created.",
851 + "flash.pr_closed": "Pull request #%d closed.",
852 + "flash.pr_reopened": "Pull request #%d reopened.",
853 + "flash.pr_merged": "Pull request #%d merged.",
854 + "flash.pr_merged_state": "Cannot change the state of a merged pull request.",
855 + "flash.pr_merge_open_only": "Only an open pull request can be merged.",
856 + "flash.pr_merge_failed": "Could not merge: %s",
857 + "flash.release_tag_empty": "Tag name cannot be empty.",
858 + "flash.release_tag_failed": "Could not create the tag: %s",
859 + "flash.release_create_failed": "Could not create the release: %s",
860 + "flash.release_published": "Release %s published.",
861 + "flash.release_delete_failed": "Could not delete the release: %s",
862 + "flash.release_deleted": "Release deleted.",
863 + "flash.release_published_with_assets": "Release %s published with %d attached file(s).",
864 + "flash.release_published_assets_failed": "Release %s published, but some files could not be saved.",
865 + "flash.release_asset_too_many": "Too many files: %d max.",
866 + "flash.release_asset_too_big": "File is too large — 100 MB per-file limit.",
867 + "flash.release_asset_deleted": "File deleted.",
868 + "flash.release_asset_delete_failed": "Could not delete the file: %s",
869 + "file.new_file": "New file",
870 + "file.new_folder": "New folder",
871 + "file.path": "Path",
872 + "file.path_ph": "e.g. src/main.go",
873 + "file.folder_ph": "e.g. docs",
874 + "file.branch": "Branch",
875 + "file.content": "Content",
876 + "file.content_ph": "File contents…",
877 + "file.message": "Commit message",
878 + "file.create_file": "Create file",
879 + "file.create_folder": "Create folder",
880 + "file.folder_hint": "Git does not track empty folders — Polar adds a .gitkeep placeholder.",
881 + "flash.file_created": "File created.",
882 + "flash.folder_created": "Folder created.",
883 + "flash.file_exists": "A file with that path already exists.",
884 + "flash.path_invalid": "Invalid path.",
885 + "flash.file_create_failed": "Could not create the file.",
886 + "flash.folder_create_failed": "Could not create the folder.",
887 + "file.upload_file": "Upload files",
888 + "file.upload_dest": "Destination folder",
889 + "file.upload_dest_ph": "e.g. docs/images (optional)",
890 + "file.upload_pick": "Choose files or drag them here",
891 + "file.upload_hint": "Up to 25 files, 50 MB in total.",
892 + "file.upload_submit": "Upload",
893 + "file.upload_replace_hint": "Existing files will be overwritten.",
894 + "flash.upload_done": "Uploaded %d file(s).",
895 + "flash.upload_none": "No files selected.",
896 + "flash.upload_failed": "Could not upload the files.",
897 + "flash.upload_too_big": "Upload is too large — 50 MB limit.",
898 + "flash.upload_too_many": "Too many files: %d max.", "flash.repo_settings_saved": "Repository settings saved.",
875 899 "flash.repo_rename_failed": "Could not rename: %s",
876 900 "flash.repo_renamed": "Repository renamed to %s.",
877 901 "flash.repo_confirm_mismatch": "The name you typed does not match the repository name.",
... ... @@ -889,42 +913,46 @@ var translations = map[Lang]map[string]string{
889 913 "flash.issue_reopened": "Issue #%d reopened.",
890 914
891 915 // Error pages
892 - "err.not_found": "Not found",
893 - "err.forbidden": "Access denied",
894 - "err.session_error": "Session error",
895 - "err.session_create": "Could not create a session.",
896 - "err.settings_error": "Settings error",
897 - "err.settings_save": "Could not save the setting.",
898 - "err.explore_disabled": "Explore is disabled on this server.",
899 - "err.activity_disabled": "Activity is disabled for this repository.",
900 - "err.pulls_disabled": "Pull requests are disabled for this repository.",
901 - "err.releases_disabled": "Releases are disabled for this repository.",
902 - "err.issues_disabled": "Issues are disabled for this repository.",
903 - "err.repo_not_found": "Repository not found",
904 - "err.repo_not_found_desc": "The repository does not exist or you do not have access to it.",
905 - "err.repo_private_desc": "The repository does not exist or is private.",
906 - "err.access_denied": "Access denied",
907 - "err.repo_write_denied": "You do not have permission to modify this repository.",
908 - "err.invalid_repo_path": "Invalid repository path",
909 - "err.path_not_found": "Path not found",
910 - "err.path_not_found_desc": "Could not find the path in the repository for ref %s",
911 - "err.file_not_found": "File not found",
912 - "err.file_not_found_desc": "The requested file was not found in %s",
913 - "err.commit_not_found": "Commit not found",
914 - "err.commit_not_found_desc": "Could not find commit %s",
915 - "err.pr_not_found": "Pull request not found",
916 - "err.pr_not_found_desc": "Could not find the pull request.",
917 - "err.pr_state_forbidden": "You cannot change the state of this pull request.",
918 - "err.pr_merge_forbidden": "You do not have permission to merge pull requests.",
919 - "err.issue_not_found": "Issue not found",
920 - "err.issue_not_found_desc": "Could not find the issue.",
921 - "err.issue_state_forbidden": "You cannot change the state of this issue.",
922 - "err.delete_error": "Delete error",
923 - "err.repo_delete_failed": "Could not delete the repository.",
924 - "err.user_not_found": "User @%s does not exist.",
925 - "err.archive_error": "Archive error",
926 - "err.archive_tar_failed": "Could not create a tar.gz archive for %s",
927 - "err.archive_zip_failed": "Could not create a zip archive for %s",
916 + "err.not_found": "Not found",
917 + "err.forbidden": "Access denied",
918 + "err.session_error": "Session error",
919 + "err.session_create": "Could not create a session.",
920 + "err.settings_error": "Settings error",
921 + "err.settings_save": "Could not save the setting.",
922 + "err.explore_disabled": "Explore is disabled on this server.",
923 + "err.activity_disabled": "Activity is disabled for this repository.",
924 + "err.pulls_disabled": "Pull requests are disabled for this repository.",
925 + "err.releases_disabled": "Releases are disabled for this repository.",
926 + "err.release_not_found": "Release not found",
927 + "err.release_not_found_desc": "Could not find the release.",
928 + "err.asset_not_found": "File not found",
929 + "err.asset_not_found_desc": "The requested release file was not found.",
930 + "err.issues_disabled": "Issues are disabled for this repository.",
931 + "err.repo_not_found": "Repository not found",
932 + "err.repo_not_found_desc": "The repository does not exist or you do not have access to it.",
933 + "err.repo_private_desc": "The repository does not exist or is private.",
934 + "err.access_denied": "Access denied",
935 + "err.repo_write_denied": "You do not have permission to modify this repository.",
936 + "err.invalid_repo_path": "Invalid repository path",
937 + "err.path_not_found": "Path not found",
938 + "err.path_not_found_desc": "Could not find the path in the repository for ref %s",
939 + "err.file_not_found": "File not found",
940 + "err.file_not_found_desc": "The requested file was not found in %s",
941 + "err.commit_not_found": "Commit not found",
942 + "err.commit_not_found_desc": "Could not find commit %s",
943 + "err.pr_not_found": "Pull request not found",
944 + "err.pr_not_found_desc": "Could not find the pull request.",
945 + "err.pr_state_forbidden": "You cannot change the state of this pull request.",
946 + "err.pr_merge_forbidden": "You do not have permission to merge pull requests.",
947 + "err.issue_not_found": "Issue not found",
948 + "err.issue_not_found_desc": "Could not find the issue.",
949 + "err.issue_state_forbidden": "You cannot change the state of this issue.",
950 + "err.delete_error": "Delete error",
951 + "err.repo_delete_failed": "Could not delete the repository.",
952 + "err.user_not_found": "User @%s does not exist.",
953 + "err.archive_error": "Archive error",
954 + "err.archive_tar_failed": "Could not create a tar.gz archive for %s",
955 + "err.archive_zip_failed": "Could not create a zip archive for %s",
928 956
929 957 // Forks
930 958 "fork.forked_from": "forked from",
internal/repo/release_assets.go
+221 -0
... ... @@ -0,0 +1,221 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "crypto/rand"
6 + "database/sql"
7 + "encoding/hex"
8 + "errors"
9 + "fmt"
10 + "os"
11 + "path/filepath"
12 + "strconv"
13 + "strings"
14 + "time"
15 + "unicode/utf8"
16 +)
17 +
18 +var (
19 + ErrReleaseAssetNotFound = errors.New("release asset not found")
20 + ErrReleaseAssetInvalid = errors.New("invalid release asset")
21 +)
22 +
23 +// ReleaseAsset is an uploaded file attached to a release.
24 +type ReleaseAsset struct {
25 + ID int64
26 + ReleaseID int64
27 + UploaderID int64
28 + Filename string
29 + StoredName string
30 + Size int64
31 + ContentType string
32 + DownloadCount int64
33 + CreatedAt time.Time
34 +}
35 +
36 +// releaseAssetsSubdir lives inside the bare repository directory, so repo
37 +// deletion and renaming handle asset files together with the git data.
38 +const releaseAssetsSubdir = "polar-release-assets"
39 +
40 +// ReleaseAssetsDir returns the on-disk directory for a release's uploaded assets.
41 +func (s *Service) ReleaseAssetsDir(ownerUsername, repoName string, releaseID int64) (string, error) {
42 + if releaseID <= 0 {
43 + return "", ErrReleaseAssetNotFound
44 + }
45 + diskPath, err := s.DiskPath(ownerUsername, repoName)
46 + if err != nil {
47 + return "", err
48 + }
49 + return filepath.Join(diskPath, releaseAssetsSubdir, strconv.FormatInt(releaseID, 10)), nil
50 +}
51 +
52 +// sanitizeAssetFilename reduces an uploaded name to a safe base name.
53 +func sanitizeAssetFilename(name string) (string, error) {
54 + name = strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
55 + if idx := strings.LastIndex(name, "/"); idx >= 0 {
56 + name = name[idx+1:]
57 + }
58 + name = strings.Map(func(r rune) rune {
59 + if r < 32 || r == 127 || strings.ContainsRune("<>:\"|?*", r) {
60 + return -1
61 + }
62 + return r
63 + }, name)
64 + name = strings.Trim(name, ". ")
65 + if name == "" {
66 + return "", ErrReleaseAssetInvalid
67 + }
68 + for len(name) > 180 {
69 + _, size := utf8.DecodeLastRuneInString(name)
70 + if size <= 0 {
71 + size = 1
72 + }
73 + name = name[:len(name)-size]
74 + }
75 + name = strings.TrimRight(name, ". ")
76 + if name == "" {
77 + return "", ErrReleaseAssetInvalid
78 + }
79 + return name, nil
80 +}
81 +
82 +// SaveReleaseAsset stores an uploaded file on disk and records it in the database.
83 +func (s *Service) SaveReleaseAsset(ctx context.Context, r *Repository, releaseID, uploaderID int64, filename string, data []byte, contentType string) (*ReleaseAsset, error) {
84 + display, err := sanitizeAssetFilename(filename)
85 + if err != nil {
86 + return nil, err
87 + }
88 +
89 + dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, releaseID)
90 + if err != nil {
91 + return nil, err
92 + }
93 + if err := os.MkdirAll(dir, 0o755); err != nil {
94 + return nil, fmt.Errorf("create release assets dir: %w", err)
95 + }
96 +
97 + var token [8]byte
98 + if _, err := rand.Read(token[:]); err != nil {
99 + return nil, fmt.Errorf("generate asset token: %w", err)
100 + }
101 + stored := hex.EncodeToString(token[:]) + "_" + display
102 + full := filepath.Join(dir, stored)
103 + if err := os.WriteFile(full, data, 0o644); err != nil {
104 + return nil, fmt.Errorf("write release asset: %w", err)
105 + }
106 +
107 + contentType = strings.TrimSpace(contentType)
108 + if contentType == "" || len(contentType) > 120 {
109 + contentType = "application/octet-stream"
110 + }
111 +
112 + now := time.Now().UTC()
113 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
114 + defer cancel()
115 + res, err := s.db.ExecContext(cctx, `
116 + INSERT INTO release_assets (release_id, uploader_id, filename, stored_name, size, content_type, created_at)
117 + VALUES (?, ?, ?, ?, ?, ?, ?)
118 + `, releaseID, uploaderID, display, stored, len(data), contentType, now)
119 + if err != nil {
120 + _ = os.Remove(full)
121 + return nil, fmt.Errorf("insert release asset: %w", err)
122 + }
123 + id, _ := res.LastInsertId()
124 +
125 + return &ReleaseAsset{
126 + ID: id,
127 + ReleaseID: releaseID,
128 + UploaderID: uploaderID,
129 + Filename: display,
130 + StoredName: stored,
131 + Size: int64(len(data)),
132 + ContentType: contentType,
133 + CreatedAt: now,
134 + }, nil
135 +}
136 +
137 +func (s *Service) GetReleaseAsset(ctx context.Context, assetID int64) (*ReleaseAsset, error) {
138 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
139 + defer cancel()
140 +
141 + var a ReleaseAsset
142 + err := s.db.QueryRowContext(cctx, `
143 + SELECT id, release_id, uploader_id, filename, stored_name, size, content_type, download_count, created_at
144 + FROM release_assets
145 + WHERE id = ?
146 + `, assetID).Scan(
147 + &a.ID, &a.ReleaseID, &a.UploaderID, &a.Filename, &a.StoredName,
148 + &a.Size, &a.ContentType, &a.DownloadCount, &a.CreatedAt,
149 + )
150 + if err != nil {
151 + if errors.Is(err, sql.ErrNoRows) {
152 + return nil, ErrReleaseAssetNotFound
153 + }
154 + return nil, fmt.Errorf("get release asset: %w", err)
155 + }
156 + return &a, nil
157 +}
158 +
159 +// ListReleaseAssetsByRepo returns uploaded assets for every release of a repo,
160 +// keyed by release id. One query for the whole releases page.
161 +func (s *Service) ListReleaseAssetsByRepo(ctx context.Context, repoID int64) (map[int64][]ReleaseAsset, error) {
162 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
163 + defer cancel()
164 +
165 + rows, err := s.db.QueryContext(cctx, `
166 + SELECT a.id, a.release_id, a.uploader_id, a.filename, a.stored_name, a.size,
167 + a.content_type, a.download_count, a.created_at
168 + FROM release_assets a
169 + JOIN releases r ON r.id = a.release_id
170 + WHERE r.repo_id = ?
171 + ORDER BY a.created_at ASC, a.id ASC
172 + `, repoID)
173 + if err != nil {
174 + return nil, fmt.Errorf("list release assets: %w", err)
175 + }
176 + defer rows.Close()
177 +
178 + result := map[int64][]ReleaseAsset{}
179 + for rows.Next() {
180 + var a ReleaseAsset
181 + if err := rows.Scan(
182 + &a.ID, &a.ReleaseID, &a.UploaderID, &a.Filename, &a.StoredName,
183 + &a.Size, &a.ContentType, &a.DownloadCount, &a.CreatedAt,
184 + ); err != nil {
185 + return nil, fmt.Errorf("scan release asset: %w", err)
186 + }
187 + result[a.ReleaseID] = append(result[a.ReleaseID], a)
188 + }
189 + return result, rows.Err()
190 +}
191 +
192 +// DeleteReleaseAsset removes the database row and the file on disk.
193 +func (s *Service) DeleteReleaseAsset(ctx context.Context, r *Repository, asset *ReleaseAsset) error {
194 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
195 + defer cancel()
196 +
197 + if _, err := s.db.ExecContext(cctx, `DELETE FROM release_assets WHERE id = ?`, asset.ID); err != nil {
198 + return fmt.Errorf("delete release asset: %w", err)
199 + }
200 + if dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, asset.ReleaseID); err == nil {
201 + _ = os.Remove(filepath.Join(dir, asset.StoredName))
202 + }
203 + return nil
204 +}
205 +
206 +// RemoveReleaseAssets drops every uploaded file belonging to a release.
207 +// Database rows are removed by the ON DELETE CASCADE constraint.
208 +func (s *Service) RemoveReleaseAssets(r *Repository, releaseID int64) {
209 + dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, releaseID)
210 + if err != nil {
211 + return
212 + }
213 + _ = os.RemoveAll(dir)
214 +}
215 +
216 +// IncrementReleaseAssetDownloads bumps the download counter (best effort).
217 +func (s *Service) IncrementReleaseAssetDownloads(ctx context.Context, assetID int64) {
218 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
219 + defer cancel()
220 + _, _ = s.db.ExecContext(cctx, `UPDATE release_assets SET download_count = download_count + 1 WHERE id = ?`, assetID)
221 +}
internal/web/handlers/handlers.go
+2 -0
... ... @@ -151,6 +151,8 @@ func (h *Handler) RegisterRoutes(app *fiber.App) {
151 151 app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage)
152 152 app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit)
153 153 app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit)
154 + app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload)
155 + app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit)
154 156
155 157 // Activity
156 158 app.Get("/:owner/:repo/activity", h.RepoActivityPage)
internal/web/handlers/release_assets_test.go
+216 -0
... ... @@ -0,0 +1,216 @@
1 +package handlers
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "fmt"
7 + "io"
8 + "mime/multipart"
9 + "net/http"
10 + "net/http/httptest"
11 + "net/url"
12 + "os"
13 + "path/filepath"
14 + "strings"
15 + "testing"
16 +
17 + "polar/internal/git"
18 + "polar/internal/web/middleware"
19 +)
20 +
21 +// TestReleaseAssetsUploadDownloadDelete walks the full release-asset flow:
22 +// multipart upload at release creation, listing, download, deletion and
23 +// cleanup when the release itself is removed.
24 +func TestReleaseAssetsUploadDownloadDelete(t *testing.T) {
25 + app, authSvc, userSvc, repoSvc := setupTestApp(t)
26 + ctx := context.Background()
27 +
28 + u, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
29 + if err != nil {
30 + t.Fatalf("register: %v", err)
31 + }
32 + sess, err := authSvc.CreateSession(ctx, u.ID)
33 + if err != nil {
34 + t.Fatalf("create session: %v", err)
35 + }
36 + r, err := repoSvc.Create(ctx, u, "engine", "Analytical Engine", false)
37 + if err != nil {
38 + t.Fatalf("create repo: %v", err)
39 + }
40 +
41 + diskPath, err := repoSvc.DiskPath("alice", "engine")
42 + if err != nil {
43 + t.Fatalf("disk path: %v", err)
44 + }
45 + branch, _ := git.EnsureDefaultBranch(ctx, diskPath, r.DefaultBranch)
46 + if _, err := git.WriteFilesCommit(ctx, diskPath, branch, []git.FileToWrite{
47 + {Path: "README.md", Content: []byte("# engine")},
48 + }, "Initial commit", "alice", "alice@example.com"); err != nil {
49 + t.Fatalf("seed commit: %v", err)
50 + }
51 +
52 + // 1. Create a release with two uploaded assets (multipart form).
53 + var buf bytes.Buffer
54 + w := multipart.NewWriter(&buf)
55 + fields := map[string]string{
56 + "_csrf": sess.CSRFToken,
57 + "tag_name": "v1.0.0",
58 + "title": "First release",
59 + "body": "Release notes",
60 + "target_ref": branch,
61 + }
62 + for k, v := range fields {
63 + if err := w.WriteField(k, v); err != nil {
64 + t.Fatalf("write field %s: %v", k, err)
65 + }
66 + }
67 + for name, content := range map[string]string{
68 + "polar-linux-amd64.tar.gz": "binary-linux",
69 + "../../evil.sh": "#!/bin/sh",
70 + } {
71 + fw, err := w.CreateFormFile("assets", name)
72 + if err != nil {
73 + t.Fatalf("create form file: %v", err)
74 + }
75 + if _, err := fw.Write([]byte(content)); err != nil {
76 + t.Fatalf("write form file: %v", err)
77 + }
78 + }
79 + w.Close()
80 +
81 + req := httptest.NewRequest(http.MethodPost, "/alice/engine/releases/new", &buf)
82 + req.Header.Set("Content-Type", w.FormDataContentType())
83 + req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
84 + resp, err := app.Test(req, 20000)
85 + if err != nil {
86 + t.Fatalf("POST release: %v", err)
87 + }
88 + if resp.StatusCode != http.StatusSeeOther {
89 + body, _ := io.ReadAll(resp.Body)
90 + t.Fatalf("expected 303 after release creation, got %d: %s", resp.StatusCode, body)
91 + }
92 +
93 + rel, err := repoSvc.GetRelease(ctx, r.ID, "v1.0.0")
94 + if err != nil {
95 + t.Fatalf("get release: %v", err)
96 + }
97 + byRelease, err := repoSvc.ListReleaseAssetsByRepo(ctx, r.ID)
98 + if err != nil {
99 + t.Fatalf("list assets: %v", err)
100 + }
101 + assets := byRelease[rel.ID]
102 + if len(assets) != 2 {
103 + t.Fatalf("expected 2 assets, got %d", len(assets))
104 + }
105 +
106 + // Path traversal must be neutralized: "../../evil.sh" becomes "evil.sh".
107 + byName := map[string]string{}
108 + for _, a := range assets {
109 + byName[a.Filename] = a.StoredName
110 + }
111 + if _, ok := byName["evil.sh"]; !ok {
112 + t.Fatalf("expected sanitized filename evil.sh, got %v", byName)
113 + }
114 +
115 + // 2. Releases page lists both files.
116 + code, page := getPage(t, app, "/alice/engine/releases")
117 + if code != http.StatusOK {
118 + t.Fatalf("releases page status %d", code)
119 + }
120 + for _, name := range []string{"polar-linux-amd64.tar.gz", "evil.sh"} {
121 + if !strings.Contains(page, name) {
122 + t.Fatalf("releases page missing asset %q", name)
123 + }
124 + }
125 +
126 + // 3. Download returns content with an attachment disposition.
127 + var downloaded int64
128 + for _, a := range assets {
129 + if a.Filename != "polar-linux-amd64.tar.gz" {
130 + continue
131 + }
132 + downloaded = a.ID
133 + dlReq := httptest.NewRequest(http.MethodGet,
134 + fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
135 + dlResp, err := app.Test(dlReq, 5000)
136 + if err != nil {
137 + t.Fatalf("download asset: %v", err)
138 + }
139 + if dlResp.StatusCode != http.StatusOK {
140 + dlResp.Body.Close()
141 + t.Fatalf("download status %d", dlResp.StatusCode)
142 + }
143 + data, _ := io.ReadAll(dlResp.Body)
144 + dlResp.Body.Close()
145 + if string(data) != "binary-linux" {
146 + t.Fatalf("unexpected asset content %q", data)
147 + }
148 + if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "polar-linux-amd64.tar.gz") {
149 + t.Fatalf("unexpected content-disposition %q", cd)
150 + }
151 +
152 + rangeReq := httptest.NewRequest(http.MethodGet,
153 + fmt.Sprintf("/alice/engine/releases/%d/assets/%d/download", rel.ID, a.ID), nil)
154 + rangeReq.Header.Set("Range", "bytes=0-5")
155 + rangeResp, err := app.Test(rangeReq, 5000)
156 + if err != nil {
157 + t.Fatalf("range download: %v", err)
158 + }
159 + if rangeResp.StatusCode != http.StatusPartialContent {
160 + rangeResp.Body.Close()
161 + t.Fatalf("expected 206 for range request, got %d", rangeResp.StatusCode)
162 + }
163 + rangeData, _ := io.ReadAll(rangeResp.Body)
164 + rangeResp.Body.Close()
165 + if string(rangeData) != "binary" {
166 + t.Fatalf("unexpected range content %q", rangeData)
167 + }
168 + }
169 + if downloaded == 0 {
170 + t.Fatalf("asset not found for download")
171 + }
172 + // full download + range request both count.
173 + if a, err := repoSvc.GetReleaseAsset(ctx, downloaded); err != nil || a.DownloadCount != 2 {
174 + t.Fatalf("download counter not incremented: %+v err=%v", a, err)
175 + }
176 +
177 + // 4. Deleting a single asset removes the file from disk.
178 + delBody := "_csrf=" + url.QueryEscape(sess.CSRFToken)
179 + delReq := httptest.NewRequest(http.MethodPost,
180 + fmt.Sprintf("/alice/engine/releases/%d/assets/%d/delete", rel.ID, downloaded), strings.NewReader(delBody))
181 + delReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
182 + delReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
183 + delResp, err := app.Test(delReq, 5000)
184 + if err != nil {
185 + t.Fatalf("delete asset: %v", err)
186 + }
187 + if delResp.StatusCode != http.StatusSeeOther {
188 + t.Fatalf("expected 303 after asset deletion, got %d", delResp.StatusCode)
189 + }
190 + dir, err := repoSvc.ReleaseAssetsDir("alice", "engine", rel.ID)
191 + if err != nil {
192 + t.Fatalf("assets dir: %v", err)
193 + }
194 + if _, err := os.Stat(filepath.Join(dir, byName["polar-linux-amd64.tar.gz"])); !os.IsNotExist(err) {
195 + t.Fatalf("asset file still on disk after deletion: %v", err)
196 + }
197 + if _, err := repoSvc.GetReleaseAsset(ctx, downloaded); err == nil {
198 + t.Fatalf("asset row still present after deletion")
199 + }
200 +
201 + // 5. Deleting the release removes the remaining asset files.
202 + relDelReq := httptest.NewRequest(http.MethodPost,
203 + fmt.Sprintf("/alice/engine/releases/%d/delete", rel.ID), strings.NewReader(delBody))
204 + relDelReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
205 + relDelReq.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID})
206 + relDelResp, err := app.Test(relDelReq, 5000)
207 + if err != nil {
208 + t.Fatalf("delete release: %v", err)
209 + }
210 + if relDelResp.StatusCode != http.StatusSeeOther {
211 + t.Fatalf("expected 303 after release deletion, got %d", relDelResp.StatusCode)
212 + }
213 + if _, err := os.Stat(dir); !os.IsNotExist(err) {
214 + t.Fatalf("release assets dir still on disk after release deletion: %v", err)
215 + }
216 +}
internal/web/handlers/releases.go
+291 -6
... ... @@ -3,6 +3,9 @@ package handlers
3 3 import (
4 4 "fmt"
5 5 "html/template"
6 + "io"
7 + "os"
8 + "path/filepath"
6 9 "strconv"
7 10 "strings"
8 11
... ... @@ -14,9 +17,15 @@ import (
14 17 "polar/internal/web/views"
15 18 )
16 19
20 +const (
21 + maxReleaseAssets = 20
22 + maxReleaseAssetSize = 100 << 20 // 100 MB per file (matches the server body limit)
23 +)
24 +
17 25 type ReleaseViewItem struct {
18 26 repo.Release
19 27 BodyHTML template.HTML
28 + Assets []repo.ReleaseAsset
20 29 }
21 30
22 31 func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error {
... ... @@ -29,6 +38,7 @@ func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error {
29 38 }
30 39
31 40 releases, _ := h.repoSvc.ListReleases(c.UserContext(), r.ID)
41 + assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID)
32 42 tags, _ := git.ListTags(c.UserContext(), diskPath)
33 43
34 44 var releaseItems []ReleaseViewItem
... ... @@ -37,6 +47,7 @@ func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error {
37 47 releaseItems = append(releaseItems, ReleaseViewItem{
38 48 Release: rel,
39 49 BodyHTML: rendered,
50 + Assets: assetsByRelease[rel.ID],
40 51 })
41 52 }
42 53
... ... @@ -80,6 +91,56 @@ func (h *Handler) RepoNewReleasePage(c *fiber.Ctx) error {
80 91 return h.render(c, fiber.StatusOK, "release_new", data)
81 92 }
82 93
94 +type releaseAssetUpload struct {
95 + name string
96 + data []byte
97 + contentType string
98 +}
99 +
100 +// readReleaseAssetUploads parses the multipart body and returns the files to
101 +// attach. It fails permanently (with a flash) when limits are exceeded.
102 +func (h *Handler) readReleaseAssetUploads(c *fiber.Ctx) ([]releaseAssetUpload, error) {
103 + form, err := c.MultipartForm()
104 + if err != nil || form == nil {
105 + return nil, nil
106 + }
107 + headers := form.File["assets"]
108 + if len(headers) == 0 {
109 + return nil, nil
110 + }
111 + if len(headers) > maxReleaseAssets {
112 + return nil, fmt.Errorf("too many")
113 + }
114 +
115 + uploads := make([]releaseAssetUpload, 0, len(headers))
116 + for _, fh := range headers {
117 + if fh == nil || fh.Size <= 0 {
118 + continue
119 + }
120 + if fh.Size > maxReleaseAssetSize {
121 + return nil, fmt.Errorf("too big")
122 + }
123 + f, oerr := fh.Open()
124 + if oerr != nil {
125 + continue
126 + }
127 + data, rerr := io.ReadAll(io.LimitReader(f, maxReleaseAssetSize+1))
128 + f.Close()
129 + if rerr != nil {
130 + continue
131 + }
132 + if int64(len(data)) > maxReleaseAssetSize {
133 + return nil, fmt.Errorf("too big")
134 + }
135 + uploads = append(uploads, releaseAssetUpload{
136 + name: fh.Filename,
137 + data: data,
138 + contentType: fh.Header.Get("Content-Type"),
139 + })
140 + }
141 + return uploads, nil
142 +}
143 +
83 144 func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
84 145 r, diskPath, err := h.loadAuthorizedRepo(c, true)
85 146 if err != nil {
... ... @@ -89,6 +150,7 @@ func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
89 150 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
90 151 }
91 152
153 + backTo := fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name)
92 154 u := middleware.CurrentUser(c)
93 155 tagName := strings.TrimSpace(c.FormValue("tag_name"))
94 156 title := strings.TrimSpace(c.FormValue("title"))
... ... @@ -101,7 +163,18 @@ func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
101 163
102 164 if tagName == "" {
103 165 h.setFlashT(c, "flash.release_tag_empty")
104 - return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
166 + return c.Redirect(backTo, fiber.StatusSeeOther)
167 + }
168 +
169 + uploads, uerr := h.readReleaseAssetUploads(c)
170 + if uerr != nil {
171 + switch uerr.Error() {
172 + case "too many":
173 + h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets)
174 + default:
175 + h.setFlashT(c, "flash.release_asset_too_big")
176 + }
177 + return c.Redirect(backTo, fiber.StatusSeeOther)
105 178 }
106 179
107 180 // Create git tag if it doesn't exist yet
... ... @@ -116,20 +189,225 @@ func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
116 189 if !tagExists {
117 190 if err := git.CreateTag(c.UserContext(), diskPath, tagName, targetRef, title); err != nil {
118 191 h.setFlashT(c, "flash.release_tag_failed", err.Error())
119 - return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
192 + return c.Redirect(backTo, fiber.StatusSeeOther)
120 193 }
121 194 }
122 195
123 - if _, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease); err != nil {
196 + rel, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease)
197 + if err != nil {
124 198 h.setFlashT(c, "flash.release_create_failed", err.Error())
125 - return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
199 + return c.Redirect(backTo, fiber.StatusSeeOther)
200 + }
201 +
202 + saved, failed := 0, 0
203 + for _, up := range uploads {
204 + if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil {
205 + failed++
206 + continue
207 + }
208 + saved++
126 209 }
127 210
128 211 h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
129 - h.setFlashT(c, "flash.release_published", tagName)
212 + switch {
213 + case failed > 0:
214 + h.setFlashT(c, "flash.release_published_assets_failed", tagName)
215 + case saved > 0:
216 + h.setFlashT(c, "flash.release_published_with_assets", tagName, saved)
217 + default:
218 + h.setFlashT(c, "flash.release_published", tagName)
219 + }
130 220 return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
131 221 }
132 222
223 +// RepoReleaseAssetDownload streams an uploaded release asset.
224 +// GET /:owner/:repo/releases/:id/assets/:assetID/download
225 +func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error {
226 + r, _, err := h.loadAuthorizedRepo(c, false)
227 + if err != nil {
228 + return nil
229 + }
230 + if !r.EnableReleases {
231 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
232 + }
233 +
234 + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
235 + assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
236 + if relID <= 0 || assetID <= 0 {
237 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
238 + }
239 + if _, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID); err != nil {
240 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
241 + }
242 +
243 + asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
244 + if err != nil || asset.ReleaseID != relID {
245 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
246 + }
247 +
248 + dir, err := h.repoSvc.ReleaseAssetsDir(r.OwnerUsername, r.Name, relID)
249 + if err != nil {
250 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
251 + }
252 + f, err := os.Open(filepath.Join(dir, asset.StoredName))
253 + if err != nil {
254 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
255 + }
256 + st, err := f.Stat()
257 + if err != nil || st.IsDir() {
258 + f.Close()
259 + return h.renderErrorT(c, fiber.StatusNotFound, "err.asset_not_found", "err.asset_not_found_desc")
260 + }
261 +
262 + h.repoSvc.IncrementReleaseAssetDownloads(c.UserContext(), asset.ID)
263 + return serveReleaseAssetFile(c, f, st.Size(), asset.ContentType, asset.Filename)
264 +}
265 +
266 +// serveReleaseAssetFile streams an open file with Content-Disposition set and
267 +// support for a single HTTP Range request. The file is streamed (not served
268 +// through the fasthttp FS cache) so deleting an asset takes effect immediately.
269 +func serveReleaseAssetFile(c *fiber.Ctx, f *os.File, size int64, contentType, filename string) error {
270 + c.Set(fiber.HeaderContentType, contentType)
271 + c.Set(fiber.HeaderContentDisposition, downloadDisposition(filename))
272 + c.Set(fiber.HeaderAcceptRanges, "bytes")
273 +
274 + rangeHeader := strings.TrimSpace(c.Get(fiber.HeaderRange))
275 + if rangeHeader == "" {
276 + return c.SendStream(f, int(size))
277 + }
278 +
279 + start, length, ok := parseByteRange(rangeHeader, size)
280 + if !ok {
281 + f.Close()
282 + c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes */%d", size))
283 + return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
284 + }
285 + if _, err := f.Seek(start, io.SeekStart); err != nil {
286 + f.Close()
287 + return c.SendStatus(fiber.StatusInternalServerError)
288 + }
289 + c.Set(fiber.HeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size))
290 + c.Status(fiber.StatusPartialContent)
291 + return c.SendStream(&closingLimitedReader{r: io.LimitReader(f, length), closer: f}, int(length))
292 +}
293 +
294 +type closingLimitedReader struct {
295 + r io.Reader
296 + closer io.Closer
297 +}
298 +
299 +func (l *closingLimitedReader) Read(p []byte) (int, error) { return l.r.Read(p) }
300 +func (l *closingLimitedReader) Close() error { return l.closer.Close() }
301 +
302 +// parseByteRange parses a single "bytes=start-end" range against a known size.
303 +func parseByteRange(header string, size int64) (int64, int64, bool) {
304 + if size <= 0 || !strings.HasPrefix(header, "bytes=") {
305 + return 0, 0, false
306 + }
307 + spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes="))
308 + if strings.Contains(spec, ",") {
309 + return 0, 0, false
310 + }
311 + startStr, endStr, ok := strings.Cut(spec, "-")
312 + if !ok {
313 + return 0, 0, false
314 + }
315 + startStr, endStr = strings.TrimSpace(startStr), strings.TrimSpace(endStr)
316 +
317 + var start, end int64
318 + switch {
319 + case startStr == "":
320 + n, err := strconv.ParseInt(endStr, 10, 64)
321 + if err != nil || n <= 0 {
322 + return 0, 0, false
323 + }
324 + if n > size {
325 + n = size
326 + }
327 + start, end = size-n, size-1
328 + case endStr == "":
329 + s, err := strconv.ParseInt(startStr, 10, 64)
330 + if err != nil || s < 0 || s >= size {
331 + return 0, 0, false
332 + }
333 + start, end = s, size-1
334 + default:
335 + s, err1 := strconv.ParseInt(startStr, 10, 64)
336 + e, err2 := strconv.ParseInt(endStr, 10, 64)
337 + if err1 != nil || err2 != nil || s < 0 || s > e || s >= size {
338 + return 0, 0, false
339 + }
340 + if e >= size {
341 + e = size - 1
342 + }
343 + start, end = s, e
344 + }
345 + return start, end - start + 1, true
346 +}
347 +
348 +// downloadDisposition builds an RFC 6266/5987 attachment header for a filename
349 +// that may contain non-ASCII characters.
350 +func downloadDisposition(name string) string {
351 + fallback := strings.Map(func(r rune) rune {
352 + if r < 32 || r == '"' || r == '\\' || r > 126 {
353 + return '_'
354 + }
355 + return r
356 + }, name)
357 + fallback = strings.TrimSpace(fallback)
358 + if fallback == "" {
359 + fallback = "download"
360 + }
361 +
362 + var encoded strings.Builder
363 + for i := 0; i < len(name); i++ {
364 + ch := name[i]
365 + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
366 + ch == '!' || ch == '#' || ch == '$' || ch == '&' || ch == '+' || ch == '-' ||
367 + ch == '.' || ch == '^' || ch == '_' || ch == '`' || ch == '|' || ch == '~' {
368 + encoded.WriteByte(ch)
369 + } else {
370 + fmt.Fprintf(&encoded, "%%%02X", ch)
371 + }
372 + }
373 + return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, encoded.String())
374 +}
375 +
376 +// RepoDeleteReleaseAssetSubmit removes a single uploaded asset.
377 +// POST /:owner/:repo/releases/:id/assets/:assetID/delete
378 +func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error {
379 + r, _, err := h.loadAuthorizedRepo(c, true)
380 + if err != nil {
381 + return nil
382 + }
383 + if !r.EnableReleases {
384 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
385 + }
386 +
387 + backTo := fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name)
388 + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
389 + assetID, _ := strconv.ParseInt(c.Params("assetID"), 10, 64)
390 +
391 + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
392 + if err != nil {
393 + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
394 + }
395 + backTo += "#" + rel.TagName
396 +
397 + asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
398 + if err != nil || asset.ReleaseID != relID {
399 + h.setFlashT(c, "flash.release_asset_delete_failed", "not found")
400 + return c.Redirect(backTo, fiber.StatusSeeOther)
401 + }
402 +
403 + if err := h.repoSvc.DeleteReleaseAsset(c.UserContext(), r, asset); err != nil {
404 + h.setFlashT(c, "flash.release_asset_delete_failed", err.Error())
405 + } else {
406 + h.setFlashT(c, "flash.release_asset_deleted")
407 + }
408 + return c.Redirect(backTo, fiber.StatusSeeOther)
409 +}
410 +
133 411 func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error {
134 412 r, _, err := h.loadAuthorizedRepo(c, true)
135 413 if err != nil {
... ... @@ -140,7 +418,14 @@ func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error {
140 418 }
141 419
142 420 relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
143 - if err := h.repoSvc.DeleteRelease(c.UserContext(), relID); err != nil {
421 +
422 + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
423 + if err != nil {
424 + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
425 + }
426 +
427 + h.repoSvc.RemoveReleaseAssets(r, rel.ID)
428 + if err := h.repoSvc.DeleteRelease(c.UserContext(), rel.ID); err != nil {
144 429 h.setFlashT(c, "flash.release_delete_failed", err.Error())
145 430 } else {
146 431 h.setFlashT(c, "flash.release_deleted")
internal/web/views/pages/release_new.html
+8 -2
... ... @@ -9,7 +9,7 @@
9 9 <span>{{t .Lang "releases.new"}}</span>
10 10 </span>
11 11 </div>
12 - <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" class="card-body flex flex-col gap-3">
12 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" enctype="multipart/form-data" class="card-body flex flex-col gap-3">
13 13 <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
14 14
15 15 <div class="grid-2">
... ... @@ -45,6 +45,12 @@
45 45 <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000"></textarea>
46 46 </div>
47 47
48 + <div>
49 + <label class="label" for="assets">{{t .Lang "releases.assets_upload"}}</label>
50 + <input id="assets" name="assets" type="file" multiple class="input" style="padding:0.4rem 0.5rem;height:auto;">
51 + <div class="text-xs text-muted mt-1">{{t .Lang "releases.assets_upload_hint"}}</div>
52 + </div>
53 +
48 54 <div>
49 55 <label class="flex items-center gap-2 cursor-pointer text-sm">
50 56 <input type="checkbox" name="is_prerelease" value="true" class="switch">
... ... @@ -61,4 +67,4 @@
61 67 </form>
62 68 </section>
63 69 </div>
64 -{{end}}
70 +{{end}}
internal/web/views/pages/releases.html
+23 -2
... ... @@ -22,6 +22,7 @@
22 22 {{if .Releases}}
23 23 <div class="flex flex-col gap-6">
24 24 {{range .Releases}}
25 + {{$rel := .}}
25 26 <article class="card" id="{{.TagName}}">
26 27 <div class="card-header flex items-center justify-between flex-wrap gap-2">
27 28 <div class="flex items-center gap-2.5 flex-wrap min-w-0">
... ... @@ -63,10 +64,30 @@
63 64 {{end}}
64 65 </div>
65 66
66 - <!-- Release Assets (Source Code) -->
67 + <!-- Release Assets: uploaded files + generated source archives -->
67 68 <div class="card-body" style="border-top:1px solid hsl(var(--border));padding:0.6rem 0.9rem;background:hsl(var(--muted)/0.15);">
68 69 <div class="text-xs font-semibold text-muted uppercase mb-2" style="letter-spacing:0.04em;">{{t $.Lang "releases.assets"}}</div>
69 70 <div class="flex flex-col gap-1.5">
71 + {{range .Assets}}
72 + <div class="flex items-center justify-between gap-3">
73 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$rel.ID}}/assets/{{.ID}}/download" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline min-w-0" style="color:hsl(var(--primary));">
74 + {{icon "file" "size-3.5"}}
75 + <span class="truncate">{{.Filename}}</span>
76 + </a>
77 + <div class="flex items-center gap-2 flex-shrink-0">
78 + <span class="text-xs text-muted">{{formatSize .Size}}</span>
79 + {{if $.CanWrite}}
80 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$rel.ID}}/assets/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.asset_delete_confirm"}}');" style="margin:0;">
81 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
82 + <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.6rem;height:1.6rem;" title="{{t $.Lang "releases.asset_delete"}}">
83 + {{icon "trash" "size-3"}}
84 + </button>
85 + </form>
86 + {{end}}
87 + </div>
88 + </div>
89 + {{end}}
90 +
70 91 <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.TagName}}.zip" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline" style="color:hsl(var(--primary));">
71 92 {{icon "download" "size-3.5"}}
72 93 <span>{{t $.Lang "releases.source_zip"}}</span>
... ... @@ -88,4 +109,4 @@
88 109 {{end}}
89 110 {{end}}
90 111 </div>
91 -{{end}}
112 +{{end}}