package db import ( "database/sql" "errors" "fmt" "unicode/utf8" ) // ErrReleaseExists marks a tag that already has a release in the repo. var ErrReleaseExists = errors.New("release already exists for tag") // Release title/notes caps, shared by every release-creation path (the // push hook, the API, and future UI). const ( ReleaseTitleMax = 300 ReleaseNotesMax = 64 << 10 ) // releaseTruncate cuts s to max bytes without splitting a UTF-8 rune. func releaseTruncate(s string, max int) string { if len(s) <= max { return s } for max > 0 && !utf8.RuneStart(s[max]) { max-- } return s[:max] } // Release is a row in the releases table: a title and markdown notes // attached to a tag that already exists in the repository. type Release struct { ID int64 RepoID int64 Tag string Title string Notes string AuthorID sql.NullInt64 CreatedAt int64 } // ReleaseAsset is a file uploaded to a release. StoredName is the opaque // name on disk; Filename is the name shown to users. type ReleaseAsset struct { ID int64 ReleaseID int64 Filename string StoredName string Size int64 CreatedAt int64 } const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, created_at FROM releases WHERE ` // CreateRelease stores a release for repoID's tag. A tag may have at most // one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists. // Title and notes are capped to the shared limits. func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) { id, err := execLastID(database, `INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`, repoID, tag, releaseTruncate(title, ReleaseTitleMax), releaseTruncate(notes, ReleaseNotesMax), nullableID(authorID)) if err != nil { if isUniqueViolation(err) { return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists) } return Release{}, fmt.Errorf("create release: %w", err) } return GetReleaseByID(database, id) } // GetReleaseByID looks up a release by primary key. func GetReleaseByID(database *sql.DB, id int64) (Release, error) { return getRelease(database, releaseColumns+`id = ?`, id) } // GetReleaseByTag looks up a repo's release by tag. func GetReleaseByTag(database *sql.DB, repoID int64, tag string) (Release, error) { return getRelease(database, releaseColumns+`repo_id = ? AND tag = ?`, repoID, tag) } // scanRelease reads one releases row (see releaseColumns) into r. func scanRelease(s rowScanner, r *Release) error { return s.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.AuthorID, &r.CreatedAt) } func getRelease(database *sql.DB, query string, args ...any) (Release, error) { var r Release err := scanRelease(database.QueryRow(query, args...), &r) if errors.Is(err, sql.ErrNoRows) { return Release{}, fmt.Errorf("release: %w", ErrNotFound) } if err != nil { return Release{}, fmt.Errorf("get release: %w", err) } return r, nil } // ListReleases returns a repo's releases, newest first. func ListReleases(database *sql.DB, repoID int64) ([]Release, error) { releases, err := listQuery(database, releaseColumns+`repo_id = ? ORDER BY created_at DESC, id DESC`, []any{repoID}, scanRelease) if err != nil { return nil, fmt.Errorf("list releases: %w", err) } return releases, nil } // DeleteRelease removes a release, scoped to its repo; its assets cascade. func DeleteRelease(database *sql.DB, repoID, id int64) error { return execScoped(database, "delete release", `DELETE FROM releases WHERE id = ? AND repo_id = ?`, id, repoID) } const releaseAssetColumns = `SELECT id, release_id, filename, stored_name, size, created_at FROM release_assets WHERE ` // CreateReleaseAsset records an uploaded file on a release. func CreateReleaseAsset(database *sql.DB, releaseID int64, filename, storedName string, size int64) (ReleaseAsset, error) { id, err := execLastID(database, `INSERT INTO release_assets (release_id, filename, stored_name, size) VALUES (?, ?, ?, ?)`, releaseID, filename, storedName, size) if err != nil { return ReleaseAsset{}, fmt.Errorf("create release asset: %w", err) } return getReleaseAsset(database, releaseAssetColumns+`id = ?`, id) } // scanReleaseAsset reads one release_assets row into a. func scanReleaseAsset(s rowScanner, a *ReleaseAsset) error { return s.Scan(&a.ID, &a.ReleaseID, &a.Filename, &a.StoredName, &a.Size, &a.CreatedAt) } func getReleaseAsset(database *sql.DB, query string, args ...any) (ReleaseAsset, error) { var a ReleaseAsset err := scanReleaseAsset(database.QueryRow(query, args...), &a) if errors.Is(err, sql.ErrNoRows) { return ReleaseAsset{}, fmt.Errorf("release asset: %w", ErrNotFound) } if err != nil { return ReleaseAsset{}, fmt.Errorf("get release asset: %w", err) } return a, nil } // ListReleaseAssets returns a release's assets oldest first. func ListReleaseAssets(database *sql.DB, releaseID int64) ([]ReleaseAsset, error) { assets, err := listQuery(database, releaseAssetColumns+`release_id = ? ORDER BY created_at, id`, []any{releaseID}, scanReleaseAsset) if err != nil { return nil, fmt.Errorf("list release assets: %w", err) } return assets, nil } // GetReleaseAssetForRepo looks up an asset scoped to a repo, so a download // cannot reach another repo's release by guessing an id. func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, error) { return getReleaseAsset(database, `SELECT a.id, a.release_id, a.filename, a.stored_name, a.size, a.created_at FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = ? AND r.repo_id = ?`, id, repoID) } // GetReleaseAssetByFilename looks up a release's asset by its display name, // for replacing an asset re-uploaded under the same name. func GetReleaseAssetByFilename(database *sql.DB, releaseID int64, filename string) (ReleaseAsset, error) { return getReleaseAsset(database, releaseAssetColumns+`release_id = ? AND filename = ?`, releaseID, filename) } // ReplaceReleaseAsset updates an asset's stored name and size in place (the // display name is unchanged), so a re-upload can swap its file without a // delete+insert pair. func ReplaceReleaseAsset(database *sql.DB, releaseID, id int64, storedName string, size int64) error { _, err := database.Exec( `UPDATE release_assets SET stored_name = ?, size = ? WHERE id = ? AND release_id = ?`, storedName, size, id, releaseID) if err != nil { return fmt.Errorf("replace asset %d: %w", id, err) } return nil } // DeleteReleaseAsset removes an asset, scoped to its release. func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error { return execScoped(database, "delete release asset", `DELETE FROM release_assets WHERE id = ? AND release_id = ?`, id, releaseID) }