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

fix: restrict write access to owners and collaborators; theme merged states; release editor

- CanWrite no longer treats instance admins as writers on repos they do not own (also collaborator management and the settings page); read access unchanged
- Merged PR icons/labels were styled with the palette accent (invisible in both themes); they now use the themed add colour plus a tinted badge
- Releases gain an edit page (title, notes, pre-release flag, asset add/remove) with a pencil button next to delete
R rin · 2026-10-16 19:36 UTC
11 файлов изменено +245 -12
internal/i18n/i18n.go
+20 -0
... ... @@ -205,6 +205,12 @@ var translations = map[Lang]map[string]string{
205 205 "releases.prerelease": "Пре-релиз (нестабильная сборка)",
206 206 "releases.publish": "Опубликовать релиз",
207 207 "releases.delete": "Удалить релиз",
208 + "releases.edit": "Редактировать релиз",
209 + "releases.edit_title": "Редактирование релиза",
210 + "releases.save_btn": "Сохранить изменения",
211 + "releases.current_assets": "Текущие файлы",
212 + "releases.add_assets": "Добавить файлы",
213 + "releases.tag_locked": "Тег нельзя изменить после публикации.",
208 214 "releases.assets": "Файлы",
209 215 "releases.source_zip": "Исходный код (zip)",
210 216 "releases.source_tar": "Исходный код (tar.gz)",
... ... @@ -354,6 +360,10 @@ var translations = map[Lang]map[string]string{
354 360 "flash.release_published": "Релиз %s опубликован.",
355 361 "flash.release_delete_failed": "Не удалось удалить релиз: %s",
356 362 "flash.release_deleted": "Релиз удалён.",
363 + "flash.release_updated": "Релиз %s обновлён.",
364 + "flash.release_update_failed": "Не удалось обновить релиз: %s",
365 + "flash.release_updated_with_assets": "Релиз %s обновлён, файлов добавлено: %d.",
366 + "flash.release_updated_assets_failed": "Релиз %s обновлён, но часть файлов не удалось сохранить.",
357 367 "flash.release_published_with_assets": "Релиз %s опубликован, файлов прикреплено: %d.",
358 368 "flash.release_published_assets_failed": "Релиз %s опубликован, но часть файлов не удалось сохранить.",
359 369 "flash.release_asset_too_many": "Слишком много файлов: максимум %d.",
... ... @@ -713,6 +723,12 @@ var translations = map[Lang]map[string]string{
713 723 "releases.prerelease": "This is a pre-release",
714 724 "releases.publish": "Publish release",
715 725 "releases.delete": "Delete release",
726 + "releases.edit": "Edit release",
727 + "releases.edit_title": "Edit release",
728 + "releases.save_btn": "Save changes",
729 + "releases.current_assets": "Current files",
730 + "releases.add_assets": "Add files",
731 + "releases.tag_locked": "The tag cannot be changed after publishing.",
716 732 "releases.assets": "Assets",
717 733 "releases.source_zip": "Source code (zip)",
718 734 "releases.source_tar": "Source code (tar.gz)",
... ... @@ -862,6 +878,10 @@ var translations = map[Lang]map[string]string{
862 878 "flash.release_published": "Release %s published.",
863 879 "flash.release_delete_failed": "Could not delete the release: %s",
864 880 "flash.release_deleted": "Release deleted.",
881 + "flash.release_updated": "Release %s updated.",
882 + "flash.release_update_failed": "Could not update the release: %s",
883 + "flash.release_updated_with_assets": "Release %s updated with %d new file(s).",
884 + "flash.release_updated_assets_failed": "Release %s updated, but some files could not be saved.",
865 885 "flash.release_published_with_assets": "Release %s published with %d attached file(s).",
866 886 "flash.release_published_assets_failed": "Release %s published, but some files could not be saved.",
867 887 "flash.release_asset_too_many": "Too many files: %d max.",
internal/repo/collab.go
+4 -3
... ... @@ -139,13 +139,14 @@ func (s *Service) CanRead(ctx context.Context, r *Repository, u *user.User) bool
139 139 return role == RoleRead || role == RoleWrite
140 140 }
141 141
142 -// CanWrite reports whether the user may push to the repository (owner, admin
143 -// or a collaborator with the write role).
142 +// CanWrite reports whether the user may push to the repository (owner or a
143 +// collaborator with the write role). Instance admins get no write access to
144 +// repositories they do not own.
144 145 func (s *Service) CanWrite(ctx context.Context, r *Repository, u *user.User) bool {
145 146 if r == nil || u == nil {
146 147 return false
147 148 }
148 - if u.ID == r.OwnerID || u.IsAdmin {
149 + if u.ID == r.OwnerID {
149 150 return true
150 151 }
151 152 return s.GetCollaboratorRole(ctx, r.ID, u.ID) == RoleWrite
internal/web/handlers/collaborators.go
+2 -2
... ... @@ -17,7 +17,7 @@ func (h *Handler) RepoCollaboratorAddSubmit(c *fiber.Ctx) error {
17 17 return nil
18 18 }
19 19 viewer := middleware.CurrentUser(c)
20 - if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) {
20 + if viewer == nil || viewer.ID != r.OwnerID {
21 21 return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden")
22 22 }
23 23
... ... @@ -39,7 +39,7 @@ func (h *Handler) RepoCollaboratorRemoveSubmit(c *fiber.Ctx) error {
39 39 return nil
40 40 }
41 41 viewer := middleware.CurrentUser(c)
42 - if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) {
42 + if viewer == nil || viewer.ID != r.OwnerID {
43 43 return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden")
44 44 }
45 45
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", h.RepoReleasesPage)
152 152 app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage)
153 153 app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit)
154 + app.Get("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleasePage)
155 + app.Post("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleaseSubmit)
154 156 app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit)
155 157 app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload)
156 158 app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit)
internal/web/handlers/releases.go
+97 -0
... ... @@ -220,6 +220,100 @@ func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
220 220 return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
221 221 }
222 222
223 +// RepoEditReleasePage renders the editor for an existing release.
224 +// GET /:owner/:repo/releases/:id/edit
225 +func (h *Handler) RepoEditReleasePage(c *fiber.Ctx) error {
226 + r, _, err := h.loadAuthorizedRepo(c, true)
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 + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
236 + if err != nil {
237 + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
238 + }
239 +
240 + assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID)
241 +
242 + viewer := middleware.CurrentUser(c)
243 + data := h.baseData(c, h.tr(c, "releases.edit")+" · "+r.OwnerUsername+"/"+r.Name)
244 + data["Repo"] = r
245 + data["Ref"] = r.DefaultBranch
246 + data["ActiveTab"] = "releases"
247 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
248 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
249 + data["Release"] = rel
250 + data["Assets"] = assetsByRelease[rel.ID]
251 +
252 + return h.render(c, fiber.StatusOK, "release_edit", data)
253 +}
254 +
255 +// RepoEditReleaseSubmit saves edited metadata and any newly attached files.
256 +// POST /:owner/:repo/releases/:id/edit
257 +func (h *Handler) RepoEditReleaseSubmit(c *fiber.Ctx) error {
258 + r, _, err := h.loadAuthorizedRepo(c, true)
259 + if err != nil {
260 + return nil
261 + }
262 + if !r.EnableReleases {
263 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
264 + }
265 +
266 + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
267 + backTo := fmt.Sprintf("/%s/%s/releases/%d/edit", r.OwnerUsername, r.Name, relID)
268 +
269 + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID)
270 + if err != nil {
271 + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
272 + }
273 +
274 + uploads, uerr := h.readReleaseAssetUploads(c)
275 + if uerr != nil {
276 + switch uerr.Error() {
277 + case "too many":
278 + h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets)
279 + default:
280 + h.setFlashT(c, "flash.release_asset_too_big")
281 + }
282 + return c.Redirect(backTo, fiber.StatusSeeOther)
283 + }
284 +
285 + u := middleware.CurrentUser(c)
286 + title := strings.TrimSpace(c.FormValue("title"))
287 + body := strings.TrimSpace(c.FormValue("body"))
288 + isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1"
289 +
290 + if err := h.repoSvc.UpdateRelease(c.UserContext(), rel.ID, title, body, isPrerelease); err != nil {
291 + h.setFlashT(c, "flash.release_update_failed", err.Error())
292 + return c.Redirect(backTo, fiber.StatusSeeOther)
293 + }
294 +
295 + saved, failed := 0, 0
296 + for _, up := range uploads {
297 + if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil {
298 + failed++
299 + continue
300 + }
301 + saved++
302 + }
303 +
304 + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
305 + switch {
306 + case failed > 0:
307 + h.setFlashT(c, "flash.release_updated_assets_failed", rel.TagName)
308 + case saved > 0:
309 + h.setFlashT(c, "flash.release_updated_with_assets", rel.TagName, saved)
310 + default:
311 + h.setFlashT(c, "flash.release_updated", rel.TagName)
312 + }
313 +
314 + return c.Redirect(fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName), fiber.StatusSeeOther)
315 +}
316 +
223 317 // RepoReleaseAssetDownload streams an uploaded release asset.
224 318 // GET /:owner/:repo/releases/:id/assets/:assetID/download
225 319 func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error {
... ... @@ -393,6 +487,9 @@ func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error {
393 487 return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc")
394 488 }
395 489 backTo += "#" + rel.TagName
490 + if next := strings.TrimSpace(c.FormValue("next")); strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
491 + backTo = next
492 + }
396 493
397 494 asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID)
398 495 if err != nil || asset.ReleaseID != relID {
internal/web/handlers/repo.go
+1 -1
... ... @@ -322,7 +322,7 @@ func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error {
322 322 viewer := middleware.CurrentUser(c)
323 323 collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID)
324 324 data["Collaborators"] = collaborators
325 - data["CanManageCollab"] = viewer != nil && (viewer.ID == r.OwnerID || viewer.IsAdmin)
325 + data["CanManageCollab"] = viewer != nil && viewer.ID == r.OwnerID
326 326
327 327 return h.render(c, fiber.StatusOK, "repo_settings", data)
328 328 }
internal/web/views/pages/activity.html
+1 -1
... ... @@ -67,7 +67,7 @@
67 67 {{if eq .Type "commit"}}
68 68 <span style="color:hsl(var(--primary));">{{icon "git-commit" "size-4"}}</span>
69 69 {{else if eq .Type "pull"}}
70 - <span style="color:hsl(var(--accent));">{{icon "git-pull-request" "size-4"}}</span>
70 + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-4"}}</span>
71 71 {{else if eq .Type "issue"}}
72 72 <span style="color:hsl(var(--primary));">{{icon "issue" "size-4"}}</span>
73 73 {{else if eq .Type "release"}}
internal/web/views/pages/pull_detail.html
+2 -2
... ... @@ -34,7 +34,7 @@
34 34
35 35 <div class="flex items-center gap-2 mt-2 text-xs text-muted flex-wrap">
36 36 {{if eq .PR.Status "merged"}}
37 - <span class="badge badge-primary font-medium flex items-center gap-1.5" style="background:hsl(var(--accent));color:#fff;">
37 + <span class="badge font-medium flex items-center gap-1.5" style="background:hsl(var(--diff-add-bg));color:hsl(var(--diff-add-fg));border-color:hsl(var(--diff-add-fg)/0.35);">
38 38 {{icon "git-merge" "size-3.5"}} {{t .Lang "pulls.status.merged"}}
39 39 </span>
40 40 {{else if eq .PR.Status "closed"}}
... ... @@ -212,7 +212,7 @@
212 212 <div class="card mb-4">
213 213 {{if eq .PR.Status "merged"}}
214 214 <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);">
215 - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-6"}}</span>
215 + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-6"}}</span>
216 216 <div class="flex flex-col gap-0.5">
217 217 <div class="font-semibold text-sm">{{t .Lang "pulls.merged_message"}}</div>
218 218 <div class="text-xs text-muted">
internal/web/views/pages/pulls.html
+3 -3
... ... @@ -9,7 +9,7 @@
9 9 <span>{{t .Lang "pulls.open"}} ({{.OpenCount}})</span>
10 10 </a>
11 11 <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=merged" class="btn {{if eq .CurrentState "merged"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
12 - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-3.5"}}</span>
12 + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-3.5"}}</span>
13 13 <span>{{t .Lang "pulls.merged"}} ({{.MergedCount}})</span>
14 14 </a>
15 15 <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=closed" class="btn {{if eq .CurrentState "closed"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
... ... @@ -33,7 +33,7 @@
33 33 <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;">
34 34 <div class="flex items-center gap-2.5 min-w-0">
35 35 {{if eq .Status "merged"}}
36 - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-4"}}</span>
36 + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-4"}}</span>
37 37 {{else if eq .Status "closed"}}
38 38 <span class="text-muted">{{icon "close" "size-4"}}</span>
39 39 {{else}}
... ... @@ -51,7 +51,7 @@
51 51 <span>{{timeAgo .CreatedAt $.Lang}}</span>
52 52 {{if eq .Status "merged"}}
53 53 <span>·</span>
54 - <span style="color:hsl(var(--accent));font-weight:500;">{{t $.Lang "pulls.merged"}}</span>
54 + <span style="color:hsl(var(--diff-add-fg));font-weight:500;">{{t $.Lang "pulls.merged"}}</span>
55 55 {{end}}
56 56 </div>
57 57 </div>
internal/web/views/pages/release_edit.html
+110 -0
... ... @@ -0,0 +1,110 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <section class="card mb-4">
6 + <div class="card-header flex items-center justify-between flex-wrap gap-2">
7 + <span class="font-semibold text-sm flex items-center gap-2">
8 + {{icon "edit" "size-4"}}
9 + <span>{{t .Lang "releases.edit_title"}}</span>
10 + </span>
11 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Release.TagName}}" class="badge font-mono text-xs flex items-center gap-1">
12 + {{icon "tag" "size-3"}}
13 + <span>{{.Release.TagName}}</span>
14 + </a>
15 + </div>
16 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/{{.Release.ID}}/edit" enctype="multipart/form-data" class="card-body flex flex-col gap-3">
17 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
18 +
19 + <div>
20 + <label class="label" for="title">{{t .Lang "releases.title"}}</label>
21 + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "releases.title_ph"}}" maxlength="200" value="{{.Release.Title}}" autofocus>
22 + </div>
23 +
24 + <div>
25 + <label class="label" for="body">{{t .Lang "releases.body"}}</label>
26 + <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000">{{.Release.Body}}</textarea>
27 + </div>
28 +
29 + {{if .Assets}}
30 + <div class="flex flex-col gap-1.5">
31 + <div class="label" style="margin-bottom:0;">{{t .Lang "releases.current_assets"}}</div>
32 + {{range .Assets}}
33 + <div class="flex items-center justify-between gap-3">
34 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.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));">
35 + {{icon "file" "size-3.5"}}
36 + <span class="truncate">{{.Filename}}</span>
37 + </a>
38 + <div class="flex items-center gap-2 flex-shrink-0">
39 + <span class="text-xs text-muted">{{formatSize .Size}}</span>
40 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.ID}}/assets/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.asset_delete_confirm"}}');" style="margin:0;">
41 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
42 + <input type="hidden" name="next" value="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.ID}}/edit">
43 + <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.6rem;height:1.6rem;" title="{{t $.Lang "releases.asset_delete"}}">
44 + {{icon "trash" "size-3"}}
45 + </button>
46 + </form>
47 + </div>
48 + </div>
49 + {{end}}
50 + </div>
51 + {{end}}
52 +
53 + <div class="flex flex-col gap-1">
54 + <label class="label" for="assets" style="margin-bottom:0;">{{t .Lang "releases.add_assets"}}</label>
55 + <label class="upload-drop" id="assets-drop" for="assets">
56 + {{icon "upload" "size-5"}}
57 + <span class="text-sm font-medium">{{t .Lang "file.upload_pick"}}</span>
58 + <span class="text-xs">{{t .Lang "releases.assets_upload_hint"}}</span>
59 + <input id="assets" name="assets" type="file" multiple style="display:none;">
60 + </label>
61 + <div id="assets-list" class="text-xs font-mono text-muted"></div>
62 + </div>
63 +
64 + <div>
65 + <label class="flex items-center gap-2 cursor-pointer text-sm">
66 + <input type="checkbox" name="is_prerelease" value="true" class="switch" {{if .Release.IsPrerelease}}checked{{end}}>
67 + <span>{{t .Lang "releases.is_prerelease"}}</span>
68 + </label>
69 + </div>
70 +
71 + <div class="flex items-center justify-between gap-2 flex-wrap">
72 + <span class="text-xs text-muted">{{t .Lang "releases.tag_locked"}}</span>
73 + <div class="flex items-center gap-2">
74 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases#{{.Release.TagName}}" class="btn btn-ghost btn-sm">{{t .Lang "common.cancel"}}</a>
75 + <button type="submit" class="btn btn-primary btn-sm">
76 + {{icon "check" "size-3.5"}}
77 + <span>{{t .Lang "releases.save_btn"}}</span>
78 + </button>
79 + </div>
80 + </div>
81 + </form>
82 + </section>
83 +</div>
84 +<script>
85 +(function () {
86 + var input = document.getElementById('assets');
87 + var drop = document.getElementById('assets-drop');
88 + var list = document.getElementById('assets-list');
89 + if (!input || !drop || !list) return;
90 + function render() {
91 + var names = [];
92 + for (var i = 0; i < input.files.length; i++) names.push(input.files[i].name);
93 + list.textContent = names.length ? names.join(' · ') : '';
94 + }
95 + input.addEventListener('change', render);
96 + ['dragover', 'dragenter'].forEach(function (ev) {
97 + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('drag'); });
98 + });
99 + ['dragleave', 'drop'].forEach(function (ev) {
100 + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('drag'); });
101 + });
102 + drop.addEventListener('drop', function (e) {
103 + if (e.dataTransfer && e.dataTransfer.files.length) {
104 + input.files = e.dataTransfer.files;
105 + render();
106 + }
107 + });
108 +})();
109 +</script>
110 +{{end}}
internal/web/views/pages/releases.html
+3 -0
... ... @@ -46,6 +46,9 @@
46 46 <span>{{formatDate .PublishedAt}}</span>
47 47 </div>
48 48 {{if $.CanWrite}}
49 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/edit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.edit"}}">
50 + {{icon "edit" "size-3.5"}}
51 + </a>
49 52 <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.delete_confirm"}}');" style="margin:0;">
50 53 <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
51 54 <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.delete"}}">