package repo import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" ) var ( ErrReleaseAssetNotFound = errors.New("release asset not found") ErrReleaseAssetInvalid = errors.New("invalid release asset") ) // ReleaseAsset is an uploaded file attached to a release. type ReleaseAsset struct { ID int64 ReleaseID int64 UploaderID int64 Filename string StoredName string Size int64 ContentType string DownloadCount int64 CreatedAt time.Time } // releaseAssetsSubdir lives inside the bare repository directory, so repo // deletion and renaming handle asset files together with the git data. const releaseAssetsSubdir = "polar-release-assets" // ReleaseAssetsDir returns the on-disk directory for a release's uploaded assets. func (s *Service) ReleaseAssetsDir(ownerUsername, repoName string, releaseID int64) (string, error) { if releaseID <= 0 { return "", ErrReleaseAssetNotFound } diskPath, err := s.DiskPath(ownerUsername, repoName) if err != nil { return "", err } return filepath.Join(diskPath, releaseAssetsSubdir, strconv.FormatInt(releaseID, 10)), nil } // sanitizeAssetFilename reduces an uploaded name to a safe base name. func sanitizeAssetFilename(name string) (string, error) { name = strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")) if idx := strings.LastIndex(name, "/"); idx >= 0 { name = name[idx+1:] } name = strings.Map(func(r rune) rune { if r < 32 || r == 127 || strings.ContainsRune("<>:\"|?*", r) { return -1 } return r }, name) name = strings.Trim(name, ". ") if name == "" { return "", ErrReleaseAssetInvalid } for len(name) > 180 { _, size := utf8.DecodeLastRuneInString(name) if size <= 0 { size = 1 } name = name[:len(name)-size] } name = strings.TrimRight(name, ". ") if name == "" { return "", ErrReleaseAssetInvalid } return name, nil } // SaveReleaseAsset stores an uploaded file on disk and records it in the database. func (s *Service) SaveReleaseAsset(ctx context.Context, r *Repository, releaseID, uploaderID int64, filename string, data []byte, contentType string) (*ReleaseAsset, error) { display, err := sanitizeAssetFilename(filename) if err != nil { return nil, err } dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, releaseID) if err != nil { return nil, err } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create release assets dir: %w", err) } var token [8]byte if _, err := rand.Read(token[:]); err != nil { return nil, fmt.Errorf("generate asset token: %w", err) } stored := hex.EncodeToString(token[:]) + "_" + display full := filepath.Join(dir, stored) if err := os.WriteFile(full, data, 0o644); err != nil { return nil, fmt.Errorf("write release asset: %w", err) } contentType = strings.TrimSpace(contentType) if contentType == "" || len(contentType) > 120 { contentType = "application/octet-stream" } now := time.Now().UTC() cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() res, err := s.db.ExecContext(cctx, ` INSERT INTO release_assets (release_id, uploader_id, filename, stored_name, size, content_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, releaseID, uploaderID, display, stored, len(data), contentType, now) if err != nil { _ = os.Remove(full) return nil, fmt.Errorf("insert release asset: %w", err) } id, _ := res.LastInsertId() return &ReleaseAsset{ ID: id, ReleaseID: releaseID, UploaderID: uploaderID, Filename: display, StoredName: stored, Size: int64(len(data)), ContentType: contentType, CreatedAt: now, }, nil } func (s *Service) GetReleaseAsset(ctx context.Context, assetID int64) (*ReleaseAsset, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var a ReleaseAsset err := s.db.QueryRowContext(cctx, ` SELECT id, release_id, uploader_id, filename, stored_name, size, content_type, download_count, created_at FROM release_assets WHERE id = ? `, assetID).Scan( &a.ID, &a.ReleaseID, &a.UploaderID, &a.Filename, &a.StoredName, &a.Size, &a.ContentType, &a.DownloadCount, &a.CreatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrReleaseAssetNotFound } return nil, fmt.Errorf("get release asset: %w", err) } return &a, nil } // ListReleaseAssetsByRepo returns uploaded assets for every release of a repo, // keyed by release id. One query for the whole releases page. func (s *Service) ListReleaseAssetsByRepo(ctx context.Context, repoID int64) (map[int64][]ReleaseAsset, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, ` SELECT a.id, a.release_id, a.uploader_id, a.filename, a.stored_name, a.size, a.content_type, a.download_count, a.created_at FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE r.repo_id = ? ORDER BY a.created_at ASC, a.id ASC `, repoID) if err != nil { return nil, fmt.Errorf("list release assets: %w", err) } defer rows.Close() result := map[int64][]ReleaseAsset{} for rows.Next() { var a ReleaseAsset if err := rows.Scan( &a.ID, &a.ReleaseID, &a.UploaderID, &a.Filename, &a.StoredName, &a.Size, &a.ContentType, &a.DownloadCount, &a.CreatedAt, ); err != nil { return nil, fmt.Errorf("scan release asset: %w", err) } result[a.ReleaseID] = append(result[a.ReleaseID], a) } return result, rows.Err() } // DeleteReleaseAsset removes the database row and the file on disk. func (s *Service) DeleteReleaseAsset(ctx context.Context, r *Repository, asset *ReleaseAsset) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() if _, err := s.db.ExecContext(cctx, `DELETE FROM release_assets WHERE id = ?`, asset.ID); err != nil { return fmt.Errorf("delete release asset: %w", err) } if dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, asset.ReleaseID); err == nil { _ = os.Remove(filepath.Join(dir, asset.StoredName)) } return nil } // RemoveReleaseAssets drops every uploaded file belonging to a release. // Database rows are removed by the ON DELETE CASCADE constraint. func (s *Service) RemoveReleaseAssets(r *Repository, releaseID int64) { dir, err := s.ReleaseAssetsDir(r.OwnerUsername, r.Name, releaseID) if err != nil { return } _ = os.RemoveAll(dir) } // IncrementReleaseAssetDownloads bumps the download counter (best effort). func (s *Service) IncrementReleaseAssetDownloads(ctx context.Context, assetID int64) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, _ = s.db.ExecContext(cctx, `UPDATE release_assets SET download_count = download_count + 1 WHERE id = ?`, assetID) }