config_service.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "os"
  8. "path/filepath"
  9. "sort"
  10. "strings"
  11. "cmr-backend/internal/apperr"
  12. "cmr-backend/internal/platform/assets"
  13. "cmr-backend/internal/platform/security"
  14. "cmr-backend/internal/store/postgres"
  15. )
  16. type ConfigService struct {
  17. store *postgres.Store
  18. localEventDir string
  19. assetBaseURL string
  20. publisher *assets.OSSUtilPublisher
  21. }
  22. type ConfigPipelineSummary struct {
  23. SourceTable string `json:"sourceTable"`
  24. BuildTable string `json:"buildTable"`
  25. ReleaseAssetsTable string `json:"releaseAssetsTable"`
  26. }
  27. type LocalEventFile struct {
  28. FileName string `json:"fileName"`
  29. FullPath string `json:"fullPath"`
  30. }
  31. type EventConfigSourceView struct {
  32. ID string `json:"id"`
  33. EventID string `json:"eventId"`
  34. SourceVersionNo int `json:"sourceVersionNo"`
  35. SourceKind string `json:"sourceKind"`
  36. SchemaID string `json:"schemaId"`
  37. SchemaVersion string `json:"schemaVersion"`
  38. Status string `json:"status"`
  39. Notes *string `json:"notes,omitempty"`
  40. Source map[string]any `json:"source"`
  41. }
  42. type EventConfigBuildView struct {
  43. ID string `json:"id"`
  44. EventID string `json:"eventId"`
  45. SourceID string `json:"sourceId"`
  46. BuildNo int `json:"buildNo"`
  47. BuildStatus string `json:"buildStatus"`
  48. BuildLog *string `json:"buildLog,omitempty"`
  49. Manifest map[string]any `json:"manifest"`
  50. AssetIndex []map[string]any `json:"assetIndex"`
  51. }
  52. type PublishedReleaseView struct {
  53. EventID string `json:"eventId"`
  54. Release ResolvedReleaseView `json:"release"`
  55. ReleaseNo int `json:"releaseNo"`
  56. PublishedAt string `json:"publishedAt"`
  57. }
  58. type ImportLocalEventConfigInput struct {
  59. EventPublicID string
  60. FileName string `json:"fileName"`
  61. Notes *string `json:"notes,omitempty"`
  62. }
  63. type BuildPreviewInput struct {
  64. SourceID string `json:"sourceId"`
  65. }
  66. type PublishBuildInput struct {
  67. BuildID string `json:"buildId"`
  68. }
  69. func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
  70. return &ConfigService{
  71. store: store,
  72. localEventDir: localEventDir,
  73. assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
  74. publisher: publisher,
  75. }
  76. }
  77. func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
  78. return ConfigPipelineSummary{
  79. SourceTable: "event_config_sources",
  80. BuildTable: "event_config_builds",
  81. ReleaseAssetsTable: "event_release_assets",
  82. }
  83. }
  84. func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
  85. dir, err := filepath.Abs(s.localEventDir)
  86. if err != nil {
  87. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  88. }
  89. entries, err := os.ReadDir(dir)
  90. if err != nil {
  91. return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
  92. }
  93. files := make([]LocalEventFile, 0)
  94. for _, entry := range entries {
  95. if entry.IsDir() {
  96. continue
  97. }
  98. if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
  99. continue
  100. }
  101. files = append(files, LocalEventFile{
  102. FileName: entry.Name(),
  103. FullPath: filepath.Join(dir, entry.Name()),
  104. })
  105. }
  106. sort.Slice(files, func(i, j int) bool {
  107. return files[i].FileName < files[j].FileName
  108. })
  109. return files, nil
  110. }
  111. func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
  112. event, err := s.requireEvent(ctx, eventPublicID)
  113. if err != nil {
  114. return nil, err
  115. }
  116. items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
  117. if err != nil {
  118. return nil, err
  119. }
  120. results := make([]EventConfigSourceView, 0, len(items))
  121. for i := range items {
  122. view, err := buildEventConfigSourceView(&items[i], event.PublicID)
  123. if err != nil {
  124. return nil, err
  125. }
  126. results = append(results, *view)
  127. }
  128. return results, nil
  129. }
  130. func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
  131. record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
  132. if err != nil {
  133. return nil, err
  134. }
  135. if record == nil {
  136. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  137. }
  138. return buildEventConfigSourceView(record, "")
  139. }
  140. func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
  141. record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
  142. if err != nil {
  143. return nil, err
  144. }
  145. if record == nil {
  146. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  147. }
  148. return buildEventConfigBuildView(record)
  149. }
  150. func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
  151. event, err := s.requireEvent(ctx, input.EventPublicID)
  152. if err != nil {
  153. return nil, err
  154. }
  155. fileName := strings.TrimSpace(filepath.Base(input.FileName))
  156. if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
  157. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
  158. }
  159. dir, err := filepath.Abs(s.localEventDir)
  160. if err != nil {
  161. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  162. }
  163. path := filepath.Join(dir, fileName)
  164. raw, err := os.ReadFile(path)
  165. if err != nil {
  166. return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
  167. }
  168. source := map[string]any{}
  169. if err := json.Unmarshal(raw, &source); err != nil {
  170. return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
  171. }
  172. if err := validateSourceConfig(source); err != nil {
  173. return nil, err
  174. }
  175. nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
  176. if err != nil {
  177. return nil, err
  178. }
  179. note := input.Notes
  180. if note == nil || strings.TrimSpace(*note) == "" {
  181. defaultNote := "imported from local event file: " + fileName
  182. note = &defaultNote
  183. }
  184. tx, err := s.store.Begin(ctx)
  185. if err != nil {
  186. return nil, err
  187. }
  188. defer tx.Rollback(ctx)
  189. record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
  190. EventID: event.ID,
  191. SourceVersionNo: nextVersion,
  192. SourceKind: "event_bundle",
  193. SchemaID: "event-source",
  194. SchemaVersion: resolveSchemaVersion(source),
  195. Status: "active",
  196. Source: source,
  197. Notes: note,
  198. })
  199. if err != nil {
  200. return nil, err
  201. }
  202. if err := tx.Commit(ctx); err != nil {
  203. return nil, err
  204. }
  205. return buildEventConfigSourceView(record, event.PublicID)
  206. }
  207. func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
  208. sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
  209. if err != nil {
  210. return nil, err
  211. }
  212. if sourceRecord == nil {
  213. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  214. }
  215. source, err := decodeJSONObject(sourceRecord.SourceJSON)
  216. if err != nil {
  217. return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
  218. }
  219. if err := validateSourceConfig(source); err != nil {
  220. return nil, err
  221. }
  222. buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
  223. if err != nil {
  224. return nil, err
  225. }
  226. previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
  227. manifest := s.buildPreviewManifest(source, previewReleaseID)
  228. assetIndex := s.buildAssetIndex(manifest)
  229. buildLog := "preview build generated from source " + sourceRecord.ID
  230. tx, err := s.store.Begin(ctx)
  231. if err != nil {
  232. return nil, err
  233. }
  234. defer tx.Rollback(ctx)
  235. record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
  236. EventID: sourceRecord.EventID,
  237. SourceID: sourceRecord.ID,
  238. BuildNo: buildNo,
  239. BuildStatus: "success",
  240. BuildLog: &buildLog,
  241. Manifest: manifest,
  242. AssetIndex: assetIndex,
  243. })
  244. if err != nil {
  245. return nil, err
  246. }
  247. if err := tx.Commit(ctx); err != nil {
  248. return nil, err
  249. }
  250. return buildEventConfigBuildView(record)
  251. }
  252. func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
  253. buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
  254. if err != nil {
  255. return nil, err
  256. }
  257. if buildRecord == nil {
  258. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  259. }
  260. if buildRecord.BuildStatus != "success" {
  261. return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
  262. }
  263. event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
  264. if err != nil {
  265. return nil, err
  266. }
  267. if event == nil {
  268. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  269. }
  270. manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
  271. if err != nil {
  272. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
  273. }
  274. assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
  275. if err != nil {
  276. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
  277. }
  278. releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
  279. if err != nil {
  280. return nil, err
  281. }
  282. releasePublicID, err := security.GeneratePublicID("rel")
  283. if err != nil {
  284. return nil, err
  285. }
  286. configLabel := deriveConfigLabel(event, manifest, releaseNo)
  287. manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
  288. assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
  289. checksum := security.HashText(buildRecord.ManifestJSON)
  290. routeCode := deriveRouteCode(manifest)
  291. if s.publisher == nil || !s.publisher.Enabled() {
  292. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
  293. }
  294. if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
  295. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
  296. }
  297. if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
  298. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
  299. }
  300. tx, err := s.store.Begin(ctx)
  301. if err != nil {
  302. return nil, err
  303. }
  304. defer tx.Rollback(ctx)
  305. releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
  306. PublicID: releasePublicID,
  307. EventID: event.ID,
  308. ReleaseNo: releaseNo,
  309. ConfigLabel: configLabel,
  310. ManifestURL: manifestURL,
  311. ManifestChecksum: &checksum,
  312. RouteCode: routeCode,
  313. BuildID: &buildRecord.ID,
  314. Status: "published",
  315. PayloadJSON: buildRecord.ManifestJSON,
  316. })
  317. if err != nil {
  318. return nil, err
  319. }
  320. if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil {
  321. return nil, err
  322. }
  323. if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
  324. return nil, err
  325. }
  326. if err := tx.Commit(ctx); err != nil {
  327. return nil, err
  328. }
  329. return &PublishedReleaseView{
  330. EventID: event.PublicID,
  331. Release: ResolvedReleaseView{
  332. LaunchMode: LaunchModeManifestRelease,
  333. Source: LaunchSourceEventCurrentRelease,
  334. EventID: event.PublicID,
  335. ReleaseID: releaseRecord.PublicID,
  336. ConfigLabel: releaseRecord.ConfigLabel,
  337. ManifestURL: releaseRecord.ManifestURL,
  338. ManifestChecksumSha256: releaseRecord.ManifestChecksum,
  339. RouteCode: releaseRecord.RouteCode,
  340. },
  341. ReleaseNo: releaseRecord.ReleaseNo,
  342. PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
  343. }, nil
  344. }
  345. func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
  346. eventPublicID = strings.TrimSpace(eventPublicID)
  347. if eventPublicID == "" {
  348. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
  349. }
  350. event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
  351. if err != nil {
  352. return nil, err
  353. }
  354. if event == nil {
  355. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  356. }
  357. return event, nil
  358. }
  359. func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
  360. source, err := decodeJSONObject(record.SourceJSON)
  361. if err != nil {
  362. return nil, err
  363. }
  364. view := &EventConfigSourceView{
  365. ID: record.ID,
  366. EventID: eventPublicID,
  367. SourceVersionNo: record.SourceVersionNo,
  368. SourceKind: record.SourceKind,
  369. SchemaID: record.SchemaID,
  370. SchemaVersion: record.SchemaVersion,
  371. Status: record.Status,
  372. Notes: record.Notes,
  373. Source: source,
  374. }
  375. return view, nil
  376. }
  377. func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
  378. manifest, err := decodeJSONObject(record.ManifestJSON)
  379. if err != nil {
  380. return nil, err
  381. }
  382. assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
  383. if err != nil {
  384. return nil, err
  385. }
  386. return &EventConfigBuildView{
  387. ID: record.ID,
  388. EventID: record.EventID,
  389. SourceID: record.SourceID,
  390. BuildNo: record.BuildNo,
  391. BuildStatus: record.BuildStatus,
  392. BuildLog: record.BuildLog,
  393. Manifest: manifest,
  394. AssetIndex: assetIndex,
  395. }, nil
  396. }
  397. func validateSourceConfig(source map[string]any) error {
  398. requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
  399. value, ok := parent[key]
  400. if !ok {
  401. return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  402. }
  403. asMap, ok := value.(map[string]any)
  404. if !ok {
  405. return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
  406. }
  407. return asMap, nil
  408. }
  409. requiredString := func(parent map[string]any, key string) error {
  410. value, ok := parent[key]
  411. if !ok {
  412. return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  413. }
  414. text, ok := value.(string)
  415. if !ok || strings.TrimSpace(text) == "" {
  416. return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
  417. }
  418. return nil
  419. }
  420. if err := requiredString(source, "schemaVersion"); err != nil {
  421. return err
  422. }
  423. app, err := requiredMap(source, "app")
  424. if err != nil {
  425. return err
  426. }
  427. if err := requiredString(app, "id"); err != nil {
  428. return err
  429. }
  430. if err := requiredString(app, "title"); err != nil {
  431. return err
  432. }
  433. m, err := requiredMap(source, "map")
  434. if err != nil {
  435. return err
  436. }
  437. if err := requiredString(m, "tiles"); err != nil {
  438. return err
  439. }
  440. if err := requiredString(m, "mapmeta"); err != nil {
  441. return err
  442. }
  443. playfield, err := requiredMap(source, "playfield")
  444. if err != nil {
  445. return err
  446. }
  447. if err := requiredString(playfield, "kind"); err != nil {
  448. return err
  449. }
  450. playfieldSource, err := requiredMap(playfield, "source")
  451. if err != nil {
  452. return err
  453. }
  454. if err := requiredString(playfieldSource, "type"); err != nil {
  455. return err
  456. }
  457. if err := requiredString(playfieldSource, "url"); err != nil {
  458. return err
  459. }
  460. game, err := requiredMap(source, "game")
  461. if err != nil {
  462. return err
  463. }
  464. if err := requiredString(game, "mode"); err != nil {
  465. return err
  466. }
  467. return nil
  468. }
  469. func resolveSchemaVersion(source map[string]any) string {
  470. if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
  471. return value
  472. }
  473. return "1"
  474. }
  475. func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
  476. manifest := cloneJSONObject(source)
  477. manifest["releaseId"] = previewReleaseID
  478. manifest["preview"] = true
  479. manifest["assetBaseUrl"] = s.assetBaseURL
  480. if version, ok := manifest["version"]; !ok || version == "" {
  481. manifest["version"] = "preview"
  482. }
  483. if m, ok := manifest["map"].(map[string]any); ok {
  484. if tiles, ok := m["tiles"].(string); ok {
  485. m["tiles"] = s.normalizeAssetURL(tiles)
  486. }
  487. if meta, ok := m["mapmeta"].(string); ok {
  488. m["mapmeta"] = s.normalizeAssetURL(meta)
  489. }
  490. }
  491. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  492. if src, ok := playfield["source"].(map[string]any); ok {
  493. if url, ok := src["url"].(string); ok {
  494. src["url"] = s.normalizeAssetURL(url)
  495. }
  496. }
  497. }
  498. if assets, ok := manifest["assets"].(map[string]any); ok {
  499. for key, value := range assets {
  500. if text, ok := value.(string); ok {
  501. assets[key] = s.normalizeAssetURL(text)
  502. }
  503. }
  504. }
  505. return manifest
  506. }
  507. func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
  508. var assets []map[string]any
  509. if m, ok := manifest["map"].(map[string]any); ok {
  510. if tiles, ok := m["tiles"].(string); ok {
  511. assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
  512. }
  513. if meta, ok := m["mapmeta"].(string); ok {
  514. assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
  515. }
  516. }
  517. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  518. if src, ok := playfield["source"].(map[string]any); ok {
  519. if url, ok := src["url"].(string); ok {
  520. assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
  521. }
  522. }
  523. }
  524. if rawAssets, ok := manifest["assets"].(map[string]any); ok {
  525. keys := make([]string, 0, len(rawAssets))
  526. for key := range rawAssets {
  527. keys = append(keys, key)
  528. }
  529. sort.Strings(keys)
  530. for _, key := range keys {
  531. if url, ok := rawAssets[key].(string); ok {
  532. assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
  533. }
  534. }
  535. }
  536. return assets
  537. }
  538. func (s *ConfigService) normalizeAssetURL(value string) string {
  539. value = strings.TrimSpace(value)
  540. if value == "" {
  541. return value
  542. }
  543. if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
  544. return value
  545. }
  546. trimmed := strings.TrimPrefix(value, "../")
  547. trimmed = strings.TrimPrefix(trimmed, "./")
  548. trimmed = strings.TrimLeft(trimmed, "/")
  549. return s.assetBaseURL + "/" + trimmed
  550. }
  551. func cloneJSONObject(source map[string]any) map[string]any {
  552. raw, _ := json.Marshal(source)
  553. cloned := map[string]any{}
  554. _ = json.Unmarshal(raw, &cloned)
  555. return cloned
  556. }
  557. func decodeJSONObject(raw string) (map[string]any, error) {
  558. result := map[string]any{}
  559. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  560. return nil, err
  561. }
  562. return result, nil
  563. }
  564. func decodeJSONArray(raw string) ([]map[string]any, error) {
  565. if strings.TrimSpace(raw) == "" {
  566. return []map[string]any{}, nil
  567. }
  568. var result []map[string]any
  569. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  570. return nil, err
  571. }
  572. return result, nil
  573. }
  574. func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
  575. if app, ok := manifest["app"].(map[string]any); ok {
  576. if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
  577. return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
  578. }
  579. }
  580. if event != nil && strings.TrimSpace(event.DisplayName) != "" {
  581. return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
  582. }
  583. return fmt.Sprintf("Release %d", releaseNo)
  584. }
  585. func deriveRouteCode(manifest map[string]any) *string {
  586. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  587. if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
  588. route := strings.TrimSpace(value)
  589. return &route
  590. }
  591. }
  592. return nil
  593. }
  594. func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
  595. assets := []postgres.UpsertEventReleaseAssetParams{
  596. {
  597. EventReleaseID: eventReleaseID,
  598. AssetType: "manifest",
  599. AssetKey: "manifest",
  600. AssetURL: manifestURL,
  601. Checksum: checksum,
  602. Meta: map[string]any{"source": "published-build"},
  603. },
  604. {
  605. EventReleaseID: eventReleaseID,
  606. AssetType: "other",
  607. AssetKey: "asset-index",
  608. AssetURL: assetIndexURL,
  609. Meta: map[string]any{"source": "published-build"},
  610. },
  611. }
  612. for _, asset := range assetIndex {
  613. assetType, _ := asset["assetType"].(string)
  614. assetKey, _ := asset["assetKey"].(string)
  615. assetURL, _ := asset["assetUrl"].(string)
  616. if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
  617. continue
  618. }
  619. mappedType := assetType
  620. if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
  621. mappedType = "other"
  622. }
  623. assets = append(assets, postgres.UpsertEventReleaseAssetParams{
  624. EventReleaseID: eventReleaseID,
  625. AssetType: mappedType,
  626. AssetKey: assetKey,
  627. AssetURL: assetURL,
  628. Meta: asset,
  629. })
  630. }
  631. return assets
  632. }