rin
Публичный Open-source lightweight git hosting.
release_assets.go · 221 строка · 6.7 KB
Raw
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 }
222