chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped

- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows
- Flatten v3/ contents to repo root
- Add Doppler secrets management (project=libnovel, config=prd)
- Add justfile with doppler run wrappers for all docker compose commands
- Strip hardcoded env fallbacks from docker-compose.yml
- Add minimal README.md
- Clean up .gitignore
This commit is contained in:
Admin
2026-03-23 17:21:12 +05:00
parent 1118392811
commit 59e8cdb19a
522 changed files with 5259 additions and 80365 deletions

View File

@@ -119,10 +119,10 @@ func AvatarObjectKey(userID, ext string) string {
return fmt.Sprintf("%s/%s.%s", userID, ext, ext)
}
// BrowseObjectKey returns the MinIO object key for a cached browse page snapshot.
// Format: browse/{genre}/{sort}/{status}/{type}/page-{n}.json
func BrowseObjectKey(genre, sort, status, novelType string, page int) string {
return fmt.Sprintf("browse/%s/%s/%s/%s/page-%d.json", genre, sort, status, novelType, page)
// CoverObjectKey returns the MinIO object key for a book cover image.
// Format: covers/{slug}.jpg
func CoverObjectKey(slug string) string {
return fmt.Sprintf("covers/%s.jpg", slug)
}
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
@@ -201,16 +201,16 @@ func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string)
return keys, nil
}
// ── Browse operations ─────────────────────────────────────────────────────────
// ── Cover operations ─────────────────────────────────────────────────────────
// putBrowse stores raw JSON bytes for a browse page snapshot.
func (m *minioClient) putBrowse(ctx context.Context, key string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, "application/json", data)
// putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg.
func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, contentType, data)
}
// getBrowse retrieves a browse page snapshot. Returns (nil, false, nil) when
// the object does not exist.
func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, error) {
// getCover retrieves a cover image. Returns (nil, "", false, nil) when the
// object does not exist.
func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
@@ -220,3 +220,25 @@ func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool,
}
return data, true, nil
}
// coverExists returns true when the cover image object exists.
func (m *minioClient) coverExists(ctx context.Context, key string) bool {
return m.objectExists(ctx, m.bucketBrowse, key)
}
// coverContentType inspects the first bytes of data to determine if it is
// a JPEG or PNG image. Falls back to "image/jpeg".
func coverContentType(data []byte) string {
if len(data) >= 4 {
// PNG magic: 0x89 0x50 0x4E 0x47
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// WebP: starts with "RIFF" at 0..3 and "WEBP" at 8..11
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "image/webp"
}
}
return "image/jpeg"
}

View File

@@ -50,7 +50,7 @@ var _ bookstore.RankingStore = (*Store)(nil)
var _ bookstore.AudioStore = (*Store)(nil)
var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.BrowseStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -69,6 +69,7 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
"total_chapters": meta.TotalChapters,
"source_url": meta.SourceURL,
"ranking": meta.Ranking,
"rating": meta.Rating,
}
// Upsert via filter: if exists PATCH, otherwise POST.
existing, err := s.getBookBySlug(ctx, meta.Slug)
@@ -138,10 +139,15 @@ type pbBook struct {
TotalChapters int `json:"total_chapters"`
SourceURL string `json:"source_url"`
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
}
func (b pbBook) toDomain() domain.BookMeta {
var metaUpdated int64
if t, err := time.Parse(time.RFC3339, b.Updated); err == nil {
metaUpdated = t.Unix()
}
return domain.BookMeta{
Slug: b.Slug,
Title: b.Title,
@@ -153,6 +159,8 @@ func (b pbBook) toDomain() domain.BookMeta {
TotalChapters: b.TotalChapters,
SourceURL: b.SourceURL,
Ranking: b.Ranking,
Rating: b.Rating,
MetaUpdated: metaUpdated,
}
}
@@ -401,6 +409,17 @@ func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bo
return "", false, nil
}
func (s *Store) PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (string, error) {
// Delete existing avatar objects for this user before writing the new one
// so old extensions don't linger (e.g. old .png after uploading a .jpg).
_ = s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
key := AvatarObjectKey(userID, ext)
if err := s.mc.putObject(ctx, s.mc.bucketAvatars, key, contentType, data); err != nil {
return "", fmt.Errorf("put avatar: %w", err)
}
return key, nil
}
func (s *Store) DeleteAvatar(ctx context.Context, userID string) error {
return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
}
@@ -770,21 +789,32 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
}, nil
}
// ── BrowseStore ────────────────────────────────────────────────────────────────
// ── CoverStore ────────────────────────────────────────────────────────────────
func (s *Store) PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error {
key := BrowseObjectKey(genre, sort, status, novelType, page)
if err := s.mc.putBrowse(ctx, key, data); err != nil {
return fmt.Errorf("PutBrowsePage: %w", err)
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
key := CoverObjectKey(slug)
if contentType == "" {
contentType = coverContentType(data)
}
if err := s.mc.putCover(ctx, key, contentType, data); err != nil {
return fmt.Errorf("PutCover: %w", err)
}
return nil
}
func (s *Store) GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) {
key := BrowseObjectKey(genre, sort, status, novelType, page)
data, ok, err := s.mc.getBrowse(ctx, key)
func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) {
key := CoverObjectKey(slug)
data, ok, err := s.mc.getCover(ctx, key)
if err != nil {
return nil, false, fmt.Errorf("GetBrowsePage: %w", err)
return nil, "", false, fmt.Errorf("GetCover: %w", err)
}
return data, ok, nil
if !ok {
return nil, "", false, nil
}
ct := coverContentType(data)
return data, ct, true, nil
}
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}