#150: address covers by bytes; ReplaceSeriesCover
This commit is contained in:
@@ -704,67 +704,76 @@ func (s *Store) getCoverByAddress(address string) ([]byte, string, bool, error)
|
||||
return body, contentType, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) error {
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) (string, error) {
|
||||
stored, ok := CoverContentType(contentType)
|
||||
if !ok {
|
||||
return fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
return "", fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
}
|
||||
contentType = stored
|
||||
address := coverSourceAddress(sourceURL)
|
||||
address := CoverAddressForBytes(body)
|
||||
relativePath := coverRelativePath(address)
|
||||
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create cover shard: %w", err)
|
||||
return "", fmt.Errorf("create cover shard: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(coverPath), ".cover-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cover temp file: %w", err)
|
||||
return "", fmt.Errorf("create cover temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write cover temp file: %w", err)
|
||||
return "", fmt.Errorf("write cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync cover temp file: %w", err)
|
||||
return "", fmt.Errorf("sync cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close cover temp file: %w", err)
|
||||
return "", fmt.Errorf("close cover temp file: %w", err)
|
||||
}
|
||||
if err := os.Link(tmpName, coverPath); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return fmt.Errorf("install cover file: %w", err)
|
||||
return "", fmt.Errorf("install cover file: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO covers (address, path, content_type)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address) DO NOTHING`, address, relativePath, contentType); err != nil {
|
||||
return fmt.Errorf("record cover %q: %w", address, err)
|
||||
return "", fmt.Errorf("record cover %q: %w", address, err)
|
||||
}
|
||||
return nil
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// GetCover returns the immutable object addressed by its source URL. Missing
|
||||
// GetCover returns the immutable object a source URL's own hash names. Rows
|
||||
// written before byte addressing (ADR-0014) are the only ones that ever reach
|
||||
// it; it hashes the URL, so a byte-addressed Cover is invisible to it. Missing
|
||||
// files are reported with ok=false so callers can retry acquisition later.
|
||||
func (s *Store) GetCover(sourceURL string) ([]byte, string, bool, error) {
|
||||
return s.getCover(sourceURL)
|
||||
}
|
||||
|
||||
// PutCover persists bytes under the source URL's content address. A later
|
||||
// write for the same URL cannot replace the immutable object.
|
||||
// PutCover persists bytes under their own content address (ADR-0014). A later
|
||||
// write of the same bytes cannot replace the immutable object.
|
||||
func (s *Store) PutCover(sourceURL string, body []byte, contentType string) error {
|
||||
return s.putCover(sourceURL, body, contentType)
|
||||
_, err := s.putCover(sourceURL, body, contentType)
|
||||
return err
|
||||
}
|
||||
|
||||
// CoverAddress is the content address bytes fetched from sourceURL are stored
|
||||
// under. It is a pure function of the URL, so the acquisition path can name a
|
||||
// Cover before it has the bytes.
|
||||
func CoverAddress(sourceURL string) string { return coverSourceAddress(sourceURL) }
|
||||
// CoverAddressForBytes is the content address body is stored under: the hex
|
||||
// SHA-256 of the bytes, so identical artwork is one address and a re-art a
|
||||
// new one. Legacy rows were addressed from their source URL instead and are
|
||||
// never rehashed — both derivations coexist (ADR-0014).
|
||||
func CoverAddressForBytes(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// coverAddressRe is the shape of a stored address: the hex SHA-256 of a source
|
||||
// URL. Request paths reach CoverByAddress, so the shape is checked before the
|
||||
// value is ever turned into a filesystem path.
|
||||
// coverAddressRe is the shape of a stored address: 64 lowercase hex digits —
|
||||
// the hex SHA-256 of the cover bytes, or of the source URL for legacy rows
|
||||
// (ADR-0014). Request paths reach CoverByAddress, so the shape is checked
|
||||
// before the value is ever turned into a filesystem path; byte-derived
|
||||
// addresses keep the same shape, so the guard is unchanged.
|
||||
var coverAddressRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// CoverByAddress returns the immutable object at one content address. An
|
||||
@@ -788,24 +797,67 @@ func (s *Store) CoverWireURL(address string) string {
|
||||
return s.coverBaseURL + "/covers/" + address
|
||||
}
|
||||
|
||||
// SetSeriesCover stores the bytes and points the Series at them, but only
|
||||
// while the Series has no Cover: acquisition at creation and the poll both
|
||||
// call this, and whichever arrives second must not overwrite the first. The
|
||||
// bytes themselves are content-addressed and immutable, so storing them twice
|
||||
// is free.
|
||||
// SetSeriesCover stores the bytes and points the Series at their address, but
|
||||
// only while the Series has no Cover: acquisition at creation and the poll
|
||||
// both call this, and whichever arrives second must not overwrite the first.
|
||||
// The bytes themselves are content-addressed and immutable, so storing them
|
||||
// twice is free. See ReplaceSeriesCover for the write that may move a Cover
|
||||
// once one exists (ADR-0014).
|
||||
func (s *Store) SetSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) error {
|
||||
if err := s.putCover(sourceURL, body, contentType); err != nil {
|
||||
address, err := s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2 AND cover_address = ''`,
|
||||
site, seriesID, sourceURL, coverSourceAddress(sourceURL)); err != nil {
|
||||
site, seriesID, sourceURL, address); err != nil {
|
||||
return fmt.Errorf("set cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceSeriesCover stores the bytes and points the Series at their address
|
||||
// whether or not one already exists, writing the current source URL alongside
|
||||
// — the Forced Poll's installer and the only write that may move a Cover once
|
||||
// one exists (ADR-0014). previous is the address the row held before the write
|
||||
// ("" if it had none) and current the address of the bytes just stored; both
|
||||
// are read and written in one transaction, so a concurrent replacement reports
|
||||
// the exact displacement. previous == current means the Site served identical
|
||||
// artwork, an honest no-op; otherwise previous is stranded — its bytes stay
|
||||
// served under the covers table (ADR-0014), the row just no longer points at
|
||||
// them.
|
||||
func (s *Store) ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) (previous, current string, err error) {
|
||||
current, err = s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("begin replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.QueryRow(`
|
||||
SELECT cover_address FROM series
|
||||
WHERE site = $1 AND series_id = $2 FOR UPDATE`,
|
||||
site, seriesID).Scan(&previous)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
previous = ""
|
||||
} else if err != nil {
|
||||
return "", "", fmt.Errorf("read cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, sourceURL, current); err != nil {
|
||||
return "", "", fmt.Errorf("replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", fmt.Errorf("commit cover replace for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return previous, current, nil
|
||||
}
|
||||
|
||||
// List returns every bookmark of one reader, newest activity first.
|
||||
// Series-owned fields are joined in, so each Bookmark reads back whole and
|
||||
// flat (ADR-0004).
|
||||
|
||||
Reference in New Issue
Block a user