mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
b0fe40142a
* 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>
244 lines
7.8 KiB
Go
244 lines
7.8 KiB
Go
// 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
|
||
}
|