feat(releases): upload, download and delete release asset files
bc15b46
Файлы на момент коммита
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}} |