diff --git a/codegen/generated/public_structs.json b/codegen/generated/public_structs.json index cfdfe62db..bf01bfa81 100644 --- a/codegen/generated/public_structs.json +++ b/codegen/generated/public_structs.json @@ -24876,7 +24876,7 @@ "comments": [] }, { - "filepath": "../internal/api/anilist/tags.go", + "filepath": "..\\internal\\api\\anilist\\tags.go", "filename": "tags.go", "name": "MediaTagMap", "formattedName": "AL_MediaTagMap", @@ -27900,7 +27900,7 @@ "comments": [] }, { - "filepath": "../internal/continuity/history.go", + "filepath": "..\\internal\\continuity\\history.go", "filename": "history.go", "name": "WatchHistory", "formattedName": "Continuity_WatchHistory", @@ -30583,6 +30583,17 @@ "required": false, "public": true, "comments": [] + }, + { + "name": "Extensions", + "jsonName": "extensions", + "goType": "ExtensionsSettings", + "typescriptType": "Models_ExtensionsSettings", + "usedTypescriptType": "Models_ExtensionsSettings", + "usedStructName": "models.ExtensionsSettings", + "required": false, + "public": true, + "comments": [] } ], "comments": [], @@ -30590,6 +30601,30 @@ "models.BaseModel" ] }, + { + "filepath": "../internal/database/models/models.go", + "filename": "models.go", + "name": "ExtensionsSettings", + "formattedName": "Models_ExtensionsSettings", + "package": "models", + "fields": [ + { + "name": "MarketplaceURL", + "jsonName": "marketplaceUrl", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [ + " ExtensionsSettings holds the settings related to the extension system.", + "", + " It is reachable by plugins through the \"settings\" permission scope, e.g.", + " ctx.appSettings.get(\"extensions.marketplaceUrl\")." + ] + }, { "filepath": "../internal/database/models/models.go", "filename": "models.go", @@ -30914,7 +30949,7 @@ "comments": [] }, { - "filepath": "../internal/database/models/models.go", + "filepath": "..\\internal\\database\\models\\models.go", "filename": "models.go", "name": "LibraryPaths", "formattedName": "Models_LibraryPaths", @@ -31030,7 +31065,7 @@ "comments": [] }, { - "filepath": "../internal/database/models/models.go", + "filepath": "..\\internal\\database\\models\\models.go", "filename": "models.go", "name": "IntSlice", "formattedName": "Models_IntSlice", @@ -33409,7 +33444,7 @@ "comments": [] }, { - "filepath": "../internal/database/models/models.go", + "filepath": "..\\internal\\database\\models\\models.go", "filename": "models.go", "name": "DummyDebridFiles", "formattedName": "Models_DummyDebridFiles", @@ -33665,7 +33700,7 @@ ] }, { - "filepath": "../internal/database/models/models.go", + "filepath": "..\\internal\\database\\models\\models.go", "filename": "models.go", "name": "StringSlice", "formattedName": "Models_StringSlice", @@ -58509,7 +58544,7 @@ ] }, { - "filepath": "../internal/local/database_models.go", + "filepath": "..\\internal\\local\\database_models.go", "filename": "database_models.go", "name": "StringMap", "formattedName": "Local_StringMap", @@ -58523,7 +58558,7 @@ "comments": null }, { - "filepath": "../internal/local/database_models.go", + "filepath": "..\\internal\\local\\database_models.go", "filename": "database_models.go", "name": "LocalMangaChapterContainers", "formattedName": "Local_LocalMangaChapterContainers", @@ -60059,7 +60094,7 @@ "comments": [] }, { - "filepath": "../internal/manga/download.go", + "filepath": "..\\internal\\manga\\download.go", "filename": "download.go", "name": "MediaMap", "formattedName": "Manga_MediaMap", @@ -60074,7 +60109,7 @@ "comments": null }, { - "filepath": "../internal/manga/download.go", + "filepath": "..\\internal\\manga\\download.go", "filename": "download.go", "name": "ProviderDownloadMap", "formattedName": "Manga_ProviderDownloadMap", @@ -60502,7 +60537,7 @@ "comments": [] }, { - "filepath": "../internal/manga/downloader/chapter_downloader.go", + "filepath": "..\\internal\\manga\\downloader\\chapter_downloader.go", "filename": "chapter_downloader.go", "name": "Registry", "formattedName": "ChapterDownloader_Registry", @@ -74652,7 +74687,7 @@ "comments": [] }, { - "filepath": "../internal/nakama/share.go", + "filepath": "..\\internal\\nakama\\share.go", "filename": "share.go", "name": "NakamaCustomSourceMap", "formattedName": "Nakama_NakamaCustomSourceMap", @@ -76800,6 +76835,187 @@ ], "comments": [] }, + { + "filepath": "../internal/notification/hub.go", + "filename": "hub.go", + "name": "Hub", + "formattedName": "Hub", + "package": "notification", + "fields": [], + "comments": [] + }, + { + "filepath": "../internal/notification/hub.go", + "filename": "hub.go", + "name": "Severity", + "formattedName": "Severity", + "package": "notification", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"normal\"", + "\"info\"", + "\"success\"", + "\"warning\"", + "\"error\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/notification/hub.go", + "filename": "hub.go", + "name": "Urgency", + "formattedName": "Urgency", + "package": "notification", + "fields": [], + "aliasOf": { + "goType": "string", + "typescriptType": "string", + "declaredValues": [ + "\"silent\"", + "\"normal\"", + "\"urgent\"" + ] + }, + "comments": [] + }, + { + "filepath": "../internal/notification/hub.go", + "filename": "hub.go", + "name": "Progress", + "formattedName": "Progress", + "package": "notification", + "fields": [ + { + "name": "Current", + "jsonName": "Current", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Total", + "jsonName": "Total", + "goType": "int", + "typescriptType": "number", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Intermediate", + "jsonName": "Intermediate", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, + { + "filepath": "../internal/notification/hub.go", + "filename": "hub.go", + "name": "Notification", + "formattedName": "Notification", + "package": "notification", + "fields": [ + { + "name": "ID", + "jsonName": "id", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "EmitterId", + "jsonName": "emitterId", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Title", + "jsonName": "title", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Body", + "jsonName": "body", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Severity", + "jsonName": "severity", + "goType": "Severity", + "typescriptType": "Severity", + "usedTypescriptType": "Severity", + "usedStructName": "notification.Severity", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Urgency", + "jsonName": "urgency", + "goType": "Urgency", + "typescriptType": "Urgency", + "usedTypescriptType": "Urgency", + "usedStructName": "notification.Urgency", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "ShouldNotifySystem", + "jsonName": "shouldNotifySystem", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "HasNotifiedSystem", + "jsonName": "hasNotifiedSystem", + "goType": "bool", + "typescriptType": "boolean", + "required": true, + "public": true, + "comments": [] + }, + { + "name": "Progress", + "jsonName": "progress", + "goType": "Progress", + "typescriptType": "Progress", + "usedTypescriptType": "Progress", + "usedStructName": "notification.Progress", + "required": true, + "public": true, + "comments": [] + } + ], + "comments": [] + }, { "filepath": "../internal/notifier/notifier.go", "filename": "notifier.go", @@ -100677,6 +100893,39 @@ "required": true, "public": false, "comments": [] + }, + { + "name": "mu", + "jsonName": "mu", + "goType": "sync.Mutex", + "typescriptType": "Mutex", + "usedTypescriptType": "Mutex", + "usedStructName": "sync.Mutex", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "closeOnce", + "jsonName": "closeOnce", + "goType": "sync.Once", + "typescriptType": "Once", + "usedTypescriptType": "Once", + "usedStructName": "sync.Once", + "required": false, + "public": false, + "comments": [] + }, + { + "name": "closeErr", + "jsonName": "closeErr", + "goType": "error", + "typescriptType": "error", + "usedTypescriptType": "error", + "usedStructName": "torrentutil.error", + "required": true, + "public": false, + "comments": [] } ], "comments": [ diff --git a/internal/database/db/settings.go b/internal/database/db/settings.go index b3a39df62..1a05521cc 100644 --- a/internal/database/db/settings.go +++ b/internal/database/db/settings.go @@ -2,6 +2,7 @@ package db import ( "seanime/internal/database/models" + "seanime/internal/security" "seanime/internal/util" "strings" @@ -11,6 +12,15 @@ import ( var CurrSettings *models.Settings func (db *Database) UpsertSettings(settings *models.Settings) (*models.Settings, error) { + // Validated here because the client, the settings path endpoint and plugins all go through this + if settings != nil && settings.Extensions != nil { + settings.Extensions.MarketplaceURL = strings.TrimSpace(settings.Extensions.MarketplaceURL) + if err := security.ValidateMarketplaceUrl(settings.Extensions.MarketplaceURL); err != nil { + db.Logger.Error().Err(err).Msg("db: Rejected settings with an invalid marketplace URL") + return nil, err + } + } + if settings != nil && settings.Torrent != nil { settings.Torrent.QBittorrentHost = strings.TrimSpace(strings.Trim(settings.Torrent.QBittorrentHost, "\"")) settings.Torrent.TransmissionHost = strings.TrimSpace(strings.Trim(settings.Torrent.TransmissionHost, "\"")) @@ -307,5 +317,9 @@ func CloneSettings(settings *models.Settings) *models.Settings { if settings.Manga != nil { clone.Manga = new(*settings.Manga) } + if settings.Extensions != nil { + ext := *settings.Extensions + clone.Extensions = &ext + } return &clone } diff --git a/internal/database/models/models.go b/internal/database/models/models.go index e089787e4..e83a14c01 100644 --- a/internal/database/models/models.go +++ b/internal/database/models/models.go @@ -58,6 +58,12 @@ type Settings struct { Discord *DiscordSettings `gorm:"embedded" json:"discord"` Notifications *NotificationSettings `gorm:"embedded" json:"notifications"` Nakama *NakamaSettings `gorm:"embedded;embeddedPrefix:nakama_" json:"nakama"` + // v3.11+ + Extensions *ExtensionsSettings `gorm:"embedded;embeddedPrefix:extensions_" json:"extensions"` +} + +type ExtensionsSettings struct { + MarketplaceURL string `gorm:"column:marketplace_url" json:"marketplaceUrl"` // Empty means the default marketplace } type AnilistSettings struct { diff --git a/internal/extension_repo/goja_plugin_types/app.d.ts b/internal/extension_repo/goja_plugin_types/app.d.ts index 80a8f60d9..9479c67ee 100644 --- a/internal/extension_repo/goja_plugin_types/app.d.ts +++ b/internal/extension_repo/goja_plugin_types/app.d.ts @@ -647,8 +647,7 @@ declare namespace $app { * @file internal/continuity/hook_events.go * @description * WatchHistoryItemRequestedEvent is triggered when a watch history item is requested. - * Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object - * or set it to nil to indicate that the watch history item was not found. + * Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object or set it to nil to indicate that the watch history item was not found. */ function onWatchHistoryItemRequested(cb: (event: WatchHistoryItemRequestedEvent) => void): void; @@ -865,10 +864,10 @@ declare namespace $app { * @event DiscordPresenceAnimeActivityRequestedEvent * @file internal/discordrpc/presence/hook_events.go * @description - * DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right - * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that - * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to - * stop the activity from being sent to discord. + * DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue. + * There is no guarantee as to when or if the activity will be successfully sent to discord. + * Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. + * Prevent default to stop the activity from being sent to discord. */ function onDiscordPresenceAnimeActivityRequested(cb: (event: DiscordPresenceAnimeActivityRequestedEvent) => void): void; @@ -906,10 +905,10 @@ declare namespace $app { * @event DiscordPresenceMangaActivityRequestedEvent * @file internal/discordrpc/presence/hook_events.go * @description - * DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right - * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that - * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to - * stop the activity from being sent to discord. + * DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue. + * There is no guarantee as to when or if the activity will be successfully sent to discord. + * Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. + * Prevent default to stop the activity from being sent to discord. */ function onDiscordPresenceMangaActivityRequested(cb: (event: DiscordPresenceMangaActivityRequestedEvent) => void): void; @@ -983,8 +982,9 @@ declare namespace $app { * @event HydrateOnlinestreamFillerDataRequestedEvent * @file internal/library/fillermanager/hook_events.go * @description - * HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming - * episodes. This is used by the online streaming episode list. Prevent default to skip the default behavior and return your own data. + * HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming episodes. + * This is used by the online streaming episode list. + * Prevent default to skip the default behavior and return your own data. */ function onHydrateOnlinestreamFillerDataRequested(cb: (event: HydrateOnlinestreamFillerDataRequestedEvent) => void): void; @@ -1295,9 +1295,9 @@ declare namespace $app { * @file internal/api/metadata/hook_events.go * @description * AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned. - * In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the - * original AnimeMetadata object is not complete. This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. If the modified episode - * metadata is nil, an empty EpisodeMetadata object will be returned. + * In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete. + * This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. + * If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned. */ function onAnimeEpisodeMetadata(cb: (event: AnimeEpisodeMetadataEvent) => void): void; @@ -1664,8 +1664,8 @@ declare namespace $app { * PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested. * This event is triggered right after the media player loads an episode. * The playback manager uses the local files details to track the progress, propose next episodes, etc. - * In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media - * and anime list entry. Prevent default to skip the default fetching and override the details. + * In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry. + * Prevent default to skip the default fetching and override the details. */ function onPlaybackLocalFileDetailsRequested(cb: (event: PlaybackLocalFileDetailsRequestedEvent) => void): void; @@ -1687,8 +1687,7 @@ declare namespace $app { * @description * PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested. * Prevent default to skip the default fetching and override the details. - * In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is - * still tracked. + * In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked. */ function onPlaybackStreamDetailsRequested(cb: (event: PlaybackStreamDetailsRequestedEvent) => void): void; @@ -3743,7 +3742,7 @@ declare namespace $app { } /** - * - Filepath: internal/continuity/history.go + * - Filepath: ..\internal\continuity\history.go */ export type Continuity_WatchHistory = Record; @@ -3971,12 +3970,12 @@ declare namespace $app { } /** - * - Filepath: internal/manga/download.go + * - Filepath: ..\internal\manga\download.go */ export type Manga_MediaMap = Record; /** - * - Filepath: internal/manga/download.go + * - Filepath: ..\internal\manga\download.go */ export type Manga_ProviderDownloadMap = Record>; diff --git a/internal/handlers/extensions.go b/internal/handlers/extensions.go index e9e720a98..ce4e59e18 100644 --- a/internal/handlers/extensions.go +++ b/internal/handlers/extensions.go @@ -555,6 +555,13 @@ func (h *Handler) HandleGetMarketplaceExtensions(c echo.Context) error { marketplaceUrl, _ = url.PathUnescape(encodedMarketplaceUrl) } + // The query param is kept for older clients + if marketplaceUrl == "" { + if settings, err := h.App.Database.GetSettings(); err == nil && settings.Extensions != nil { + marketplaceUrl = strings.TrimSpace(settings.Extensions.MarketplaceURL) + } + } + if h.App.FeatureManager.IsDisabled(core.ManageExtensions) { marketplaceUrl = "" } @@ -563,7 +570,7 @@ func (h *Handler) HandleGetMarketplaceExtensions(c echo.Context) error { if targetMarketplaceUrl == "" { targetMarketplaceUrl = constants.DefaultExtensionMarketplaceURL } - if err := security.ValidateOutboundUrl(targetMarketplaceUrl); err != nil { + if err := security.ValidateMarketplaceUrl(targetMarketplaceUrl); err != nil { return h.RespondWithStatusError(c, echo.ErrForbidden.Code, err) } diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 785af95d8..38d96eb20 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -265,6 +265,13 @@ func (h *Handler) HandleSaveSettings(c echo.Context) error { autoDownloaderSettings.Enabled = false } + // Not part of this form, keep the stored value + var extensionsSettings *models.ExtensionsSettings + if prevSettings != nil && prevSettings.Extensions != nil { + clone := *prevSettings.Extensions + extensionsSettings = &clone + } + settings, err := h.App.Database.UpsertSettings(&models.Settings{ BaseModel: models.BaseModel{ ID: 1, @@ -279,6 +286,7 @@ func (h *Handler) HandleSaveSettings(c echo.Context) error { Notifications: &b.Notifications, Nakama: &b.Nakama, AutoDownloader: &autoDownloaderSettings, + Extensions: extensionsSettings, }) if err != nil { diff --git a/internal/plugin/app_settings.go b/internal/plugin/app_settings.go index 4d4072d98..91dd5eacb 100644 --- a/internal/plugin/app_settings.go +++ b/internal/plugin/app_settings.go @@ -134,16 +134,20 @@ func (a *AppContextImpl) bindSettingsObj(vm *goja.Runtime, ext *extension.Extens return rejectNow(vm, errors.New("settings path is empty")) } + // The details hold the value, so the cache key does too and a grant can't be + // reused to write a different value to the same path + details := []string{describeSettingChange(path, value)} + return a.settingsAction(vm, scheduler, ext, prompt.Options{ Kind: "settings", Action: "edit \"" + path + "\"", Resource: "Setting: \"" + path + "\"", Message: "Allow \"" + ext.Name + "\" to edit \"" + path + "\"?", - Details: []string{path}, + Details: details, AllowLabel: "Allow", DenyLabel: "Don't Allow", Cache: cache, - CacheKey: settingsCacheKey("edit", path), + CacheKey: settingsCacheKey("edit", details...), }, func() (interface{}, error) { bundle, base, err := a.getSettingsBundleAndMap() if err != nil { @@ -181,8 +185,7 @@ func (a *AppContextImpl) bindSettingsObj(vm *goja.Runtime, ext *extension.Extens return rejectNow(vm, err) } - details := []string{"all settings"} - details = diffAppSettingsPaths(currentMap, nextMap) + details := describeSettingPaths(diffAppSettingsPaths(currentMap, nextMap), nextMap) if len(details) == 0 { details = []string{"no setting changes"} } @@ -201,7 +204,7 @@ func (a *AppContextImpl) bindSettingsObj(vm *goja.Runtime, ext *extension.Extens }) _ = settingsObj.Set("patch", func(patch map[string]interface{}) goja.Value { - details := settingPaths(patch) + details := describeSettingPaths(settingPaths(patch), patch) if len(details) == 0 { details = []string{"app settings"} } @@ -233,6 +236,53 @@ func (a *AppContextImpl) bindSettingsObj(vm *goja.Runtime, ext *extension.Extens return settingsObj } +const maxSettingValueDetailLen = 120 + +// describeSettingChange renders a "path = value" line shown in the permission prompt +func describeSettingChange(path string, value interface{}) string { + return strings.TrimSpace(path) + " = " + formatSettingValue(value) +} + +func describeSettingPaths(paths []string, source map[string]interface{}) []string { + ret := make([]string, 0, len(paths)) + for _, path := range paths { + value, found := getPath(source, path) + if !found { + ret = append(ret, strings.TrimSpace(path)+" = (removed)") + continue + } + ret = append(ret, describeSettingChange(path, value)) + } + return ret +} + +func formatSettingValue(value interface{}) string { + if value == nil { + return "null" + } + + var rendered string + switch typed := value.(type) { + case string: + rendered = typed + if strings.TrimSpace(rendered) == "" { + return "(empty)" + } + default: + bytes, err := json.Marshal(value) + if err != nil { + return "(unreadable value)" + } + rendered = string(bytes) + } + + rendered = strings.Join(strings.Fields(rendered), " ") + if len(rendered) > maxSettingValueDetailLen { + rendered = rendered[:maxSettingValueDetailLen] + "…" + } + return rendered +} + func settingsCacheKey(action string, parts ...string) string { key := strings.Join(parts, "|") if key == "" { diff --git a/internal/plugin/app_settings_test.go b/internal/plugin/app_settings_test.go index a0c204b21..51193ae01 100644 --- a/internal/plugin/app_settings_test.go +++ b/internal/plugin/app_settings_test.go @@ -263,3 +263,68 @@ func TestAppSettingsPatchSavesSecondaryRoots(t *testing.T) { require.Equal(t, 1, torrentstreamSaved) require.Equal(t, 1, debridSaved) } + +func TestAppSettingsSetPathPromptShowsValueAndIsNotReusedAcrossValues(t *testing.T) { + appCtx, database, ws := newAppSettingsTestContext(t, SettingsActions{}) + seedAppSettings(t, database) + + vm, obj, _ := bindTestAppSettings(t, appCtx) + settingsObj := obj.Get("appSettings").ToObject(vm) + set, ok := goja.AssertFunction(settingsObj.Get("set")) + require.True(t, ok) + + const path = "extensions.marketplaceUrl" + const firstURL = "https://example.com/marketplace.json" + const secondURL = "https://attacker.example/marketplace.json" + + ret, err := set(settingsObj, vm.ToValue(path), vm.ToValue(firstURL)) + require.NoError(t, err) + + request := waitForSettingsPromptRequest(t, ws, 0) + require.Contains(t, request.Details, path+" = "+firstURL) + allowSettingsPrompt(ws, request.ID) + _ = requirePromiseFulfilled(t, ret) + + settings, err := database.GetSettings() + require.NoError(t, err) + require.NotNil(t, settings.Extensions) + require.Equal(t, firstURL, settings.Extensions.MarketplaceURL) + + // Writing a different value to the same path must prompt again + ret, err = set(settingsObj, vm.ToValue(path), vm.ToValue(secondURL)) + require.NoError(t, err) + + secondRequest := waitForOtherSettingsPromptRequest(t, ws, request.ID) + require.Contains(t, secondRequest.Details, path+" = "+secondURL) + allowSettingsPrompt(ws, secondRequest.ID) + _ = requirePromiseFulfilled(t, ret) + + settings, err = database.GetSettings() + require.NoError(t, err) + require.NotNil(t, settings.Extensions) + require.Equal(t, secondURL, settings.Extensions.MarketplaceURL) +} + +// waitForOtherSettingsPromptRequest waits for a prompt request other than excludeID. +// Fails if the prompt was cached, since no new request is emitted then. +func waitForOtherSettingsPromptRequest(t *testing.T, ws *events.MockWSEventManager, excludeID string) prompt.Request { + t.Helper() + + var request prompt.Request + require.Eventually(t, func() bool { + for _, event := range ws.Events() { + if event.Type != prompt.EventRequest { + continue + } + payload, ok := event.Payload.(prompt.Request) + if !ok || payload.ID == excludeID { + continue + } + request = payload + return true + } + return false + }, time.Second, 10*time.Millisecond, "expected a new prompt for a different value") + + return request +} diff --git a/internal/security/outbound.go b/internal/security/outbound.go index 665789ec9..4a1ee0770 100644 --- a/internal/security/outbound.go +++ b/internal/security/outbound.go @@ -53,6 +53,32 @@ func ValidateOutboundUrl(rawURL string) error { return nil } +// ValidateMarketplaceUrl validates a marketplace URL coming from the client or a plugin. +// The scheme and host checks run even outside strict mode, unlike ValidateOutboundUrl. +func ValidateMarketplaceUrl(rawURL string) error { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid marketplace URL: %w", err) + } + + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + default: + return fmt.Errorf("invalid marketplace URL: scheme must be http or https") + } + + if strings.TrimSpace(parsed.Hostname()) == "" { + return fmt.Errorf("invalid marketplace URL: missing host") + } + + return ValidateOutboundUrl(rawURL) +} + func isPrivateNetworkAddr(addr netip.Addr) bool { addr = addr.Unmap() return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsMulticast() || addr.IsUnspecified() diff --git a/internal/security/outbound_test.go b/internal/security/outbound_test.go index 6744ba004..580e6bc97 100644 --- a/internal/security/outbound_test.go +++ b/internal/security/outbound_test.go @@ -42,3 +42,50 @@ func TestValidateOutboundURL(t *testing.T) { } }) } + +func TestValidateMarketplaceUrl(t *testing.T) { + t.Cleanup(func() { + SetSecureMode("") + }) + + t.Run("allows an empty url", func(t *testing.T) { + SetSecureMode("") + if err := ValidateMarketplaceUrl(" "); err != nil { + t.Fatalf("expected an empty marketplace url to be allowed: %v", err) + } + }) + + t.Run("allows an https url outside strict mode", func(t *testing.T) { + SetSecureMode("") + if err := ValidateMarketplaceUrl("https://example.com/marketplace.json"); err != nil { + t.Fatalf("expected an https marketplace url to be allowed: %v", err) + } + }) + + t.Run("blocks non-http schemes outside strict mode", func(t *testing.T) { + SetSecureMode("") + for _, rawURL := range []string{ + "file:///etc/passwd", + "javascript:alert(1)", + "ftp://example.com/marketplace.json", + } { + if err := ValidateMarketplaceUrl(rawURL); err == nil { + t.Fatalf("expected %q to be blocked", rawURL) + } + } + }) + + t.Run("blocks a url without a host", func(t *testing.T) { + SetSecureMode("") + if err := ValidateMarketplaceUrl("https:///marketplace.json"); err == nil { + t.Fatal("expected a marketplace url without a host to be blocked") + } + }) + + t.Run("blocks private hosts in strict mode", func(t *testing.T) { + SetSecureMode(SecureModeStrict) + if err := ValidateMarketplaceUrl("http://192.168.1.10/marketplace.json"); err == nil { + t.Fatal("expected a private marketplace url to be blocked in strict mode") + } + }) +} diff --git a/seanime-web/package-lock.json b/seanime-web/package-lock.json index 0a603b727..e8cb00d60 100644 --- a/seanime-web/package-lock.json +++ b/seanime-web/package-lock.json @@ -2523,9 +2523,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2543,9 +2540,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2563,9 +2557,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2583,9 +2574,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2603,9 +2591,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2623,9 +2608,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3081,9 +3063,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3098,9 +3077,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3115,9 +3091,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3132,9 +3105,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3149,9 +3119,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3166,9 +3133,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8356,9 +8320,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8380,9 +8341,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8404,9 +8362,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8428,9 +8383,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/seanime-web/src/api/generated/endpoint.types.ts b/seanime-web/src/api/generated/endpoint.types.ts index 1eede973a..5abcb2ac5 100644 --- a/seanime-web/src/api/generated/endpoint.types.ts +++ b/seanime-web/src/api/generated/endpoint.types.ts @@ -15,9 +15,9 @@ import type { Anime_PlaylistEpisode, ChapterDownloader_DownloadID, Continuity_UpdateWatchHistoryItemOptions, - Debrid_TorrentItem, DebridClient_CancelStreamOptions, DebridClient_StreamPlaybackType, + Debrid_TorrentItem, HibikeTorrent_AnimeTorrent, HibikeTorrent_BatchEpisodeFiles, LibraryExplorer_SuperUpdateFileOptions, diff --git a/seanime-web/src/api/generated/endpoints.ts b/seanime-web/src/api/generated/endpoints.ts index 2aa5d48a9..5c2569665 100644 --- a/seanime-web/src/api/generated/endpoints.ts +++ b/seanime-web/src/api/generated/endpoints.ts @@ -1741,8 +1741,8 @@ export const API_ENDPOINTS = { * Route returns the episode list for the given media and provider. * It returns the episode list for the given media and provider. * The episodes are cached using a file cache. - * The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently - * fetch the sources. The episode list might be nil or empty if nothing could be found, but the media will always be returned. + * The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources. + * The episode list might be nil or empty if nothing could be found, but the media will always be returned. */ GetOnlineStreamEpisodeList: { key: "ONLINESTREAM-get-online-stream-episode-list", diff --git a/seanime-web/src/api/generated/types.ts b/seanime-web/src/api/generated/types.ts index 97637a91f..42826c925 100644 --- a/seanime-web/src/api/generated/types.ts +++ b/seanime-web/src/api/generated/types.ts @@ -1159,7 +1159,7 @@ export type AL_MediaSort = "ID" | export type AL_MediaStatus = "FINISHED" | "RELEASING" | "NOT_YET_RELEASED" | "CANCELLED" | "HIATUS" /** - * - Filepath: internal/api/anilist/tags.go + * - Filepath: ..\internal\api\anilist\tags.go * - Filename: tags.go * - Package: anilist */ @@ -1990,7 +1990,7 @@ export type Continuity_UpdateWatchHistoryItemOptions = { } /** - * - Filepath: internal/continuity/history.go + * - Filepath: ..\internal\continuity\history.go * - Filename: history.go * - Package: continuity */ @@ -3531,7 +3531,7 @@ export type Manga_PageDimension = { } /** - * - Filepath: internal/manga/download.go + * - Filepath: ..\internal\manga\download.go * - Filename: download.go * - Package: manga */ @@ -4001,7 +4001,7 @@ export type Models_DummyDebridFile = { } /** - * - Filepath: internal/database/models/models.go + * - Filepath: ..\internal\database\models\models.go * - Filename: models.go * - Package: models */ @@ -4029,6 +4029,20 @@ export type Models_DummyDebridSettings = { updatedAt?: string } +/** + * - Filepath: internal/database/models/models.go + * - Filename: models.go + * - Package: models + * @description + * ExtensionsSettings holds the settings related to the extension system. + * + * It is reachable by plugins through the "settings" permission scope, e.g. + * ctx.appSettings.get("extensions.marketplaceUrl"). + */ +export type Models_ExtensionsSettings = { + marketplaceUrl: string +} + /** * - Filepath: internal/database/models/models.go * - Filename: models.go @@ -4048,14 +4062,14 @@ export type Models_HomeItem = { } /** - * - Filepath: internal/database/models/models.go + * - Filepath: ..\internal\database\models\models.go * - Filename: models.go * - Package: models */ export type Models_IntSlice = Array /** - * - Filepath: internal/database/models/models.go + * - Filepath: ..\internal\database\models\models.go * - Filename: models.go * - Package: models */ @@ -4240,6 +4254,7 @@ export type Models_Settings = { discord?: Models_DiscordSettings notifications?: Models_NotificationSettings nakama?: Models_NakamaSettings + extensions?: Models_ExtensionsSettings id: number createdAt?: string updatedAt?: string @@ -4257,7 +4272,7 @@ export type Models_SilencedMediaEntry = { } /** - * - Filepath: internal/database/models/models.go + * - Filepath: ..\internal\database\models\models.go * - Filename: models.go * - Package: models */ @@ -4555,7 +4570,7 @@ export type Nakama_NakamaAnimeLibrary = { } /** - * - Filepath: internal/nakama/share.go + * - Filepath: ..\internal\nakama\share.go * - Filename: share.go * - Package: nakama */ diff --git a/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx b/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx index 0eeccbb1f..3f4b05e04 100644 --- a/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx +++ b/seanime-web/src/app/(main)/extensions/_containers/marketplace-extensions.tsx @@ -6,7 +6,7 @@ import { useReloadExternalExtension, } from "@/api/hooks/extensions.hooks" import { EXTENSION_TYPE } from "@/app/(main)/extensions/_containers/extension-list" -import { DEFAULT_MARKETPLACE_URL, marketplaceUrlAtom } from "@/app/(main)/extensions/_lib/marketplace.atoms" +import { DEFAULT_MARKETPLACE_URL, useMarketplaceUrl } from "@/app/(main)/extensions/_lib/marketplace.atoms" import { LANGUAGES_LIST } from "@/app/(main)/manga/_lib/language-map" import { LuffyError } from "@/components/shared/luffy-error" import { SeaImage } from "@/components/shared/sea-image" @@ -23,7 +23,6 @@ import { Select } from "@/components/ui/select" import { StaticTabs } from "@/components/ui/tabs" import { TextInput } from "@/components/ui/text-input" import { useSearchParams } from "@/lib/navigation" -import { useAtom } from "jotai/react" import capitalize from "lodash/capitalize" import orderBy from "lodash/orderBy" import React, { useMemo } from "react" @@ -48,11 +47,12 @@ export function MarketplaceExtensions(props: MarketplaceExtensionsProps) { const [searchTerm, setSearchTerm] = React.useState("") const [filterType, setFilterType] = React.useState("all") const [filterLanguage, setFilterLanguage] = React.useState("all") - const [marketplaceUrl, setMarketplaceUrl] = useAtom(marketplaceUrlAtom) + const { marketplaceUrl, setMarketplaceUrl, isUpdatingMarketplaceUrl } = useMarketplaceUrl() const [isUrlModalOpen, setIsUrlModalOpen] = React.useState(false) const [tempUrl, setTempUrl] = React.useState(marketplaceUrl) const [urlError, setUrlError] = React.useState("") - const [isUpdatingUrl, setIsUpdatingUrl] = React.useState(false) + const [isSubmittingUrl, setIsSubmittingUrl] = React.useState(false) + const isUpdatingUrl = isSubmittingUrl || isUpdatingMarketplaceUrl const isDefaultMarketplace = marketplaceUrl === DEFAULT_MARKETPLACE_URL const { data: marketplaceExtensions, isPending: isLoadingMarketplace, refetch } = useGetMarketplaceExtensions(marketplaceUrl) @@ -186,19 +186,18 @@ export function MarketplaceExtensions(props: MarketplaceExtensionsProps) { // handle URL change const handleUrlChange = async () => { if (validateUrl(tempUrl)) { - setIsUpdatingUrl(true) + setIsSubmittingUrl(true) try { - setMarketplaceUrl(tempUrl) + await setMarketplaceUrl(tempUrl) await refetch() setIsUrlModalOpen(false) - toast.success("Marketplace URL updated") } catch (error) { - toast.error("Failed to fetch extensions from the provided URL") - console.error("Error fetching extensions:", error) + toast.error("Failed to update the marketplace URL") + console.error("Error updating marketplace URL:", error) } finally { - setIsUpdatingUrl(false) + setIsSubmittingUrl(false) } } } @@ -211,19 +210,18 @@ export function MarketplaceExtensions(props: MarketplaceExtensionsProps) { // apply default URL immediately const applyDefaultUrl = async () => { - setIsUpdatingUrl(true) + setIsSubmittingUrl(true) try { - setMarketplaceUrl(DEFAULT_MARKETPLACE_URL) + await setMarketplaceUrl(DEFAULT_MARKETPLACE_URL) await refetch() setIsUrlModalOpen(false) - toast.success("Reset to default marketplace URL") } catch (error) { - toast.error("Failed to fetch extensions from the default URL") - console.error("Error fetching extensions:", error) + toast.error("Failed to reset the marketplace URL") + console.error("Error resetting marketplace URL:", error) } finally { - setIsUpdatingUrl(false) + setIsSubmittingUrl(false) } } diff --git a/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts b/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts index e78250e40..596c356d2 100644 --- a/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts +++ b/seanime-web/src/app/(main)/extensions/_lib/marketplace.atoms.ts @@ -1,12 +1,72 @@ -import { atomWithStorage } from "jotai/utils" +import { usePatchSetting } from "@/api/hooks/settings.hooks" +import { useServerStatus } from "@/app/(main)/_hooks/use-server-status" +import React from "react" -// Default marketplace URL +// Empty means the server uses the default marketplace export const DEFAULT_MARKETPLACE_URL = "" -// Atom to store the marketplace URL in localStorage -export const marketplaceUrlAtom = atomWithStorage( - "marketplace-url", - DEFAULT_MARKETPLACE_URL, - undefined, - { getOnInit: true }, -) +// The URL used to live here, it's now the extensions.marketplaceUrl setting +const LEGACY_MARKETPLACE_URL_KEY = "marketplace-url" + +function readLegacyMarketplaceUrl(): string { + if (typeof window === "undefined") return "" + try { + const raw = window.localStorage.getItem(LEGACY_MARKETPLACE_URL_KEY) + if (!raw) return "" + // atomWithStorage serializes values as JSON + const parsed = JSON.parse(raw) + return typeof parsed === "string" ? parsed.trim() : "" + } + catch (e) { + return "" + } +} + +function clearLegacyMarketplaceUrl() { + if (typeof window === "undefined") return + try { + window.localStorage.removeItem(LEGACY_MARKETPLACE_URL_KEY) + } + catch (e) { + } +} + +// Reads/writes the marketplace URL from the settings, migrating the old localStorage value once +export function useMarketplaceUrl() { + const serverStatus = useServerStatus() + const { mutateAsync: patchSetting, isPending } = usePatchSetting() + + const marketplaceUrl = serverStatus?.settings?.extensions?.marketplaceUrl || DEFAULT_MARKETPLACE_URL + + const setMarketplaceUrl = React.useCallback(async (url: string) => { + await patchSetting({ path: "extensions.marketplaceUrl", value: url.trim() }) + }, [patchSetting]) + + const hasMigrated = React.useRef(false) + + React.useEffect(() => { + if (hasMigrated.current || !serverStatus?.settings) return + hasMigrated.current = true + + const legacyUrl = readLegacyMarketplaceUrl() + if (!legacyUrl) return + + // The server value wins + if (marketplaceUrl) { + clearLegacyMarketplaceUrl() + return + } + + patchSetting({ path: "extensions.marketplaceUrl", value: legacyUrl }) + .then(() => clearLegacyMarketplaceUrl()) + .catch(() => { + // Keep the old value so it can be retried + }) + }, [serverStatus?.settings, marketplaceUrl, patchSetting]) + + return { + marketplaceUrl, + setMarketplaceUrl, + isUpdatingMarketplaceUrl: isPending, + } +}