Files
MeBox/internal/service/api_config.go
T
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00

244 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — third-party API key store.
//
// APIConfigService is a small CRUD layer over the api_configs table. It
// transparently encrypts the api_key column on write and decrypts it on
// read so values stored on disk are useless without the JWT secret.
//
// On first read it seeds the table with the providers supported by
// MeBox today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
package service
import (
"context"
"errors"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// APIConfigService coordinates third-party API key storage.
type APIConfigService struct {
log *zap.Logger
repo *repository.Container
crypto *CryptoService
}
// NewAPIConfigService is the constructor.
func NewAPIConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *APIConfigService {
return &APIConfigService{log: log, repo: repo, crypto: crypto}
}
// SeedDefaults inserts a row for every well-known provider on first run.
func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
defaults := []model.APIConfig{
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Description: "TMDb (movies + tv)", Enabled: true},
{Provider: "bangumi", BaseURL: "https://api.bgm.tv", Description: "Bangumi (anime)", Enabled: true},
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Description: "MetaTube Server 番号刮削后端", Enabled: true},
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
}
for i := range defaults {
var existing model.APIConfig
err := s.repo.DB.WithContext(ctx).
Where("provider = ?", defaults[i].Provider).
First(&existing).Error
if err == nil {
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := s.repo.DB.WithContext(ctx).Create(&defaults[i]).Error; err != nil {
return err
}
}
return nil
}
// PublicView is the safe-to-display projection of an API config row.
// The plaintext key is never returned — only a mask.
type PublicView struct {
ID string `json:"id"`
Provider string `json:"provider"`
BaseURL string `json:"base_url,omitempty"`
Extra string `json:"extra,omitempty"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
HasKey bool `json:"has_key"`
MaskedKey string `json:"masked_key,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// List returns every API config row (with masked keys).
func (s *APIConfigService) List(ctx context.Context) ([]PublicView, error) {
var rows []model.APIConfig
if err := s.repo.DB.WithContext(ctx).Order("provider asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]PublicView, 0, len(rows))
for _, r := range rows {
out = append(out, s.toPublic(&r))
}
return out, nil
}
// Get returns the public view for a single provider, or nil.
func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicView, error) {
row, err := s.findByProvider(ctx, provider)
if err != nil || row == nil {
return nil, err
}
v := s.toPublic(row)
return &v, nil
}
// Resolve returns the decrypted key + base url ready for use by an HTTP
// client. Empty struct (with no error) when the provider is unknown or
// the API key is empty.
type Resolved struct {
APIKey string
BaseURL string
Extra string
Enabled bool
}
// Resolve fetches the live configuration for a provider, decrypting the
// API key. Callers can use Resolved.APIKey != "" as the "configured" check.
func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) {
row, err := s.findByProvider(ctx, provider)
if err != nil {
s.log.Warn("api_config.resolve: query failed", zap.String("provider", provider), zap.Error(err))
return Resolved{}, err
}
if row == nil {
s.log.Warn("api_config.resolve: provider not found", zap.String("provider", provider))
return Resolved{}, nil
}
resolved := Resolved{
APIKey: s.crypto.Decrypt(row.APIKey),
BaseURL: row.BaseURL,
Extra: row.Extra,
Enabled: row.Enabled,
}
s.log.Debug("api_config.resolve: success",
zap.String("provider", provider),
zap.Bool("has_key", resolved.APIKey != ""),
zap.Bool("enabled", resolved.Enabled))
return resolved, nil
}
// Update upserts a single provider's config. An empty patch.APIKey leaves
// the existing key untouched; pass "<clear>" sentinel to wipe it.
type APIConfigPatch struct {
APIKey *string `json:"api_key,omitempty"`
BaseURL *string `json:"base_url,omitempty"`
Extra *string `json:"extra,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Description *string `json:"description,omitempty"`
}
// Update applies the patch and returns the new public view.
func (s *APIConfigService) Update(ctx context.Context, provider string, patch APIConfigPatch) (*PublicView, error) {
provider = strings.TrimSpace(strings.ToLower(provider))
if provider == "" {
return nil, errors.New("provider required")
}
row, err := s.findByProvider(ctx, provider)
if err != nil {
return nil, err
}
if row == nil {
row = &model.APIConfig{Provider: provider, Enabled: true}
if err := s.repo.DB.WithContext(ctx).Create(row).Error; err != nil {
return nil, err
}
}
updates := map[string]any{}
if patch.APIKey != nil {
v := strings.TrimSpace(*patch.APIKey)
if v == "" || v == "<clear>" {
updates["api_key"] = ""
} else {
updates["api_key"] = s.crypto.Encrypt(v)
}
}
if patch.BaseURL != nil {
updates["base_url"] = *patch.BaseURL
}
if patch.Extra != nil {
updates["extra"] = *patch.Extra
}
if patch.Enabled != nil {
updates["enabled"] = *patch.Enabled
}
if patch.Description != nil {
updates["description"] = *patch.Description
}
if len(updates) > 0 {
if err := s.repo.DB.WithContext(ctx).
Model(&model.APIConfig{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return nil, err
}
}
row, _ = s.findByProvider(ctx, provider)
v := s.toPublic(row)
return &v, nil
}
// Delete clears a provider's API key (the row stays so the masked
// description is still useful). Non-existent providers are a no-op.
func (s *APIConfigService) Delete(ctx context.Context, provider string) error {
row, err := s.findByProvider(ctx, provider)
if err != nil || row == nil {
return err
}
return s.repo.DB.WithContext(ctx).
Model(&model.APIConfig{}).
Where("id = ?", row.ID).
Update("api_key", "").Error
}
func (s *APIConfigService) findByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
var row model.APIConfig
err := s.repo.DB.WithContext(ctx).Where("provider = ?", provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *APIConfigService) toPublic(r *model.APIConfig) PublicView {
plain := s.crypto.Decrypt(r.APIKey)
pv := PublicView{
ID: r.ID,
Provider: r.Provider,
BaseURL: r.BaseURL,
Extra: r.Extra,
Enabled: r.Enabled,
Description: r.Description,
HasKey: plain != "",
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if pv.HasKey {
pv.MaskedKey = MaskAPIKey(plain)
}
return pv
}