Delete the kagane-specific cover path (#63)

The web proxy for kagane covers is dead: since #62 every Site's cover
bytes land in the content-addressed store at creation or on the poll,
and the one public route serves them all. Remove the second way to
reach a Cover:

- Bookmark.CoverURL() and the templates' use of it: templates render
  the wire value (.Cover) and nothing else.
- GET /img/kagane/{id}, web.CoverFetcher, coverIDRe: the route and its
  identifier validation are gone, and with them web/cover.go.
- store.KaganeImageID, GetKaganeCover, PutKaganeCover,
  kaganeCoverSourceURL: the proxy's persistence.
- Bookmark.CoverSource: dead once CoverURL is gone.

Acquisition keeps the browser where kagane genuinely needs it, but the
Site name leaves the routing: kaganeImageURLRe lives in browser.go with
the rest of the per-Site knowledge, browserCoverURL claims the URLs the
sidecar alone can fetch, and fetchCoverBytes routes by URL shape with
no Site argument. No plain-TLS fallback for a claimed URL — that would
only retrieve a challenge page.

Cover tests: kagane route tests removed, the general-route guarantees
they pinned kept and re-pinned — unstored and traversal-shaped
addresses serve nothing (TestPublicCoverRejectsUnknownAddress), non-image
content types are never echoed back (TestPublicCoverNeverEchoesNonImage
+ TestCoverStoreAcceptsAnySourceURL). Store content-addressing and
reopen-persistence tests rewritten against PutCover/GetCover.

No Site name remains in a cover code path outside the acquisition
module; go test ./... green.
This commit is contained in:
2026-08-10 11:24:42 +07:00
parent 78234f3c19
commit cce3d61799
16 changed files with 127 additions and 480 deletions
+2 -47
View File
@@ -40,10 +40,6 @@ type Bookmark struct {
// address and never an address that 404s (ADR-0007). A client may still
// send this field and it is discarded on the way in; see Upsert.
Cover string `json:"cover"`
// CoverSource is the third-party address the bytes were fetched from. It
// stays off the wire: it is the acquisition path's dedupe key, and no
// client is ever asked to render one.
CoverSource string `json:"-"`
LastChapter string `json:"last_chapter"`
LastChapterNum float64 `json:"last_chapter_num"`
LastChapterURL string `json:"last_chapter_url"`
@@ -155,20 +151,6 @@ func (b Bookmark) Initial() string {
return "?"
}
// kaganeCoverRe matches the cover URL kagane's og:image carries, which is what
// the userscript stores for that site.
var kaganeCoverRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
// KaganeImageID extracts the validated image id from the cover URL recorded by
// the userscript.
func KaganeImageID(cover string) (string, bool) {
m := kaganeCoverRe.FindStringSubmatch(cover)
if m == nil {
return "", false
}
return m[1], true
}
// CoverContentType canonicalises a fetched response's media type and reports
// whether the bytes are safe to store and serve. comix answers "image/jpg",
// which no standard lists but browsers accept; it is stored as the real name
@@ -184,17 +166,6 @@ func CoverContentType(contentType string) (string, bool) {
}
}
// CoverURL is the src the web UI puts in an <img>. Cover already is an address
// on this origin, so for every site but kagane it is used as-is. kagane's
// bytes still arrive through the browser-backed proxy, which is keyed by image
// id rather than by content address until #62 moves it onto the same path.
func (b Bookmark) CoverURL() string {
if imageID, ok := KaganeImageID(b.CoverSource); ok {
return "/img/kagane/" + imageID
}
return b.Cover
}
// Library buckets. A bookmark is in exactly one. This cannot be derived from
// Site: asurascans serves manga and novels from the same /comics/ path, so the
// userscript that recorded the page is the only party that knows which.
@@ -217,7 +188,7 @@ var migrations embed.FS
// compile-time constant; every request value is bound as a parameter. The
// series-owned fields are joined in from the series table, in scanBookmark
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover, s.cover_address,
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind`
@@ -562,7 +533,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
latestChapterNum sql.NullFloat64
)
if err := scan(
&b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.CoverSource, &coverAddress,
&b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
); err != nil {
@@ -617,10 +588,6 @@ func coverRelativePath(address string) string {
return address[:2] + "/" + address[2:4] + "/" + address
}
func kaganeCoverSourceURL(imageID string) string {
return "https://kagane.to/api/v2/image/" + imageID + "/compressed"
}
func (s *Store) getCover(sourceURL string) ([]byte, string, bool, error) {
return s.getCoverByAddress(coverSourceAddress(sourceURL))
}
@@ -703,18 +670,6 @@ func (s *Store) PutCover(sourceURL string, body []byte, contentType string) erro
return s.putCover(sourceURL, body, contentType)
}
// GetKaganeCover returns one persisted cover. Missing covers are reported with
// ok=false rather than as an error so the web handler can fetch them once.
func (s *Store) GetKaganeCover(imageID string) ([]byte, string, bool, error) {
return s.getCover(kaganeCoverSourceURL(imageID))
}
// PutKaganeCover persists one fetched cover. The source URL's content address
// makes each stored object immutable, so later writes for that URL are ignored.
func (s *Store) PutKaganeCover(imageID string, body []byte, contentType string) error {
return s.putCover(kaganeCoverSourceURL(imageID), body, contentType)
}
// 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.