Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316b673c3b | ||
|
|
9f73542296 |
@@ -24,16 +24,42 @@ public class GetSearchResultsHandler(IMediator mediator)
|
||||
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
|
||||
}
|
||||
|
||||
// Each ForKind call sends through IMediator to GetLibraryBrowseItemsHandler, which creates its
|
||||
// own TvContext per invocation via IDbContextFactory<TvContext> (never shares one across calls),
|
||||
// so these are safe to run concurrently.
|
||||
Task<SearchResultGroupResponseModel> movies = ForKind(LibraryBrowseMediaType.Movie);
|
||||
Task<SearchResultGroupResponseModel> shows = ForKind(LibraryBrowseMediaType.TelevisionShow);
|
||||
Task<SearchResultGroupResponseModel> seasons = ForKind(LibraryBrowseMediaType.TelevisionSeason);
|
||||
Task<SearchResultGroupResponseModel> artists = ForKind(LibraryBrowseMediaType.Artist);
|
||||
Task<SearchResultGroupResponseModel> episodes = ForKind(LibraryBrowseMediaType.Episode);
|
||||
Task<SearchResultGroupResponseModel> musicVideos = ForKind(LibraryBrowseMediaType.MusicVideo);
|
||||
Task<SearchResultGroupResponseModel> songs = ForKind(LibraryBrowseMediaType.Song);
|
||||
Task<SearchResultGroupResponseModel> otherVideos = ForKind(LibraryBrowseMediaType.OtherVideo);
|
||||
Task<SearchResultGroupResponseModel> images = ForKind(LibraryBrowseMediaType.Image);
|
||||
Task<SearchResultGroupResponseModel> remoteStreams = ForKind(LibraryBrowseMediaType.RemoteStream);
|
||||
|
||||
await Task.WhenAll(
|
||||
movies,
|
||||
shows,
|
||||
seasons,
|
||||
artists,
|
||||
episodes,
|
||||
musicVideos,
|
||||
songs,
|
||||
otherVideos,
|
||||
images,
|
||||
remoteStreams);
|
||||
|
||||
return new SearchResultsResponseModel(
|
||||
await ForKind(LibraryBrowseMediaType.Movie),
|
||||
await ForKind(LibraryBrowseMediaType.TelevisionShow),
|
||||
await ForKind(LibraryBrowseMediaType.TelevisionSeason),
|
||||
await ForKind(LibraryBrowseMediaType.Artist),
|
||||
await ForKind(LibraryBrowseMediaType.Episode),
|
||||
await ForKind(LibraryBrowseMediaType.MusicVideo),
|
||||
await ForKind(LibraryBrowseMediaType.Song),
|
||||
await ForKind(LibraryBrowseMediaType.OtherVideo),
|
||||
await ForKind(LibraryBrowseMediaType.Image),
|
||||
await ForKind(LibraryBrowseMediaType.RemoteStream));
|
||||
movies.Result,
|
||||
shows.Result,
|
||||
seasons.Result,
|
||||
artists.Result,
|
||||
episodes.Result,
|
||||
musicVideos.Result,
|
||||
songs.Result,
|
||||
otherVideos.Result,
|
||||
images.Result,
|
||||
remoteStreams.Result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,37 +23,26 @@ public class ApiControllerSecurityTests
|
||||
[Test]
|
||||
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
||||
{
|
||||
Type[] apiControllers =
|
||||
[
|
||||
typeof(ArtistsController),
|
||||
typeof(BlockController),
|
||||
typeof(ChannelController),
|
||||
typeof(CollectionController),
|
||||
typeof(DecoController),
|
||||
typeof(DecoTemplateController),
|
||||
typeof(FFmpegProfileController),
|
||||
typeof(FillerPresetController),
|
||||
typeof(ImagesController),
|
||||
typeof(LibrariesController),
|
||||
typeof(LogsController),
|
||||
typeof(MaintenanceController),
|
||||
typeof(MediaItemsController),
|
||||
typeof(MoviesController),
|
||||
typeof(PlayoutController),
|
||||
typeof(ResolutionController),
|
||||
typeof(ScannerController),
|
||||
typeof(ScheduleController),
|
||||
typeof(ScriptedScheduleController),
|
||||
typeof(SeasonsController),
|
||||
typeof(SessionController),
|
||||
typeof(ShowsController),
|
||||
typeof(SettingsController),
|
||||
typeof(SmartCollectionController),
|
||||
typeof(TemplateController),
|
||||
typeof(TraktController),
|
||||
typeof(TroubleshootController),
|
||||
typeof(WatermarkController)
|
||||
];
|
||||
// Scan the assembly instead of hand-maintaining a list of controllers here: a
|
||||
// hardcoded array silently drifts as new controllers are added (9 were missing at
|
||||
// one point, including two with real mutating actions - ArtworkUploadController POST
|
||||
// and ChannelTemplateController POST/PUT/DELETE - that were never actually checked).
|
||||
// Note: not every controller in this namespace derives from ControllerBase (e.g.
|
||||
// ChannelController, ScannerController, SessionController, MaintenanceController,
|
||||
// TroubleshootController) - filter on [ApiController] + concrete class only, matching
|
||||
// the pattern already used by ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller
|
||||
// below, so those controllers stay covered rather than silently dropping out of the scan.
|
||||
Type[] apiControllers = typeof(CollectionController)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false })
|
||||
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
||||
.ToArray();
|
||||
|
||||
// Guard against the scan silently matching nothing (e.g. a namespace rename) and
|
||||
// giving this test a false pass.
|
||||
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
|
||||
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
|
||||
+10
-10
@@ -130,16 +130,16 @@ new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generat
|
||||
`mediator.Received(1).Send(Arg.Is<Command>(...))` call. `PlayoutControllerTests.cs` is the same
|
||||
pattern for a larger, mixed-verb controller — use it as the template for a new controller with
|
||||
many actions.
|
||||
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): has a hardcoded
|
||||
`Type[] apiControllers` registry. **Add every new controller to that array** — the test walks
|
||||
every mutating (POST/PUT/PATCH/DELETE) action on each listed controller and asserts it's covered
|
||||
by the global `ApiKeyAuthorizationFilter` (or has an explicit `[SkipApiKeyAuthorizationAttribute]`
|
||||
exemption; only `ScannerController` is exempt today). **Note**: as of this writing the array does
|
||||
not include every controller under `ErsatzTV/Controllers/Api/` (e.g. `ArtworkUploadController`,
|
||||
`ChannelTemplateController`, `GraphicsElementController`, `LibraryBrowseController`,
|
||||
`SearchController`, `VersionController`, `HealthController`, `PlaylistController`,
|
||||
`MediaSourcesController` are missing) — treat that as a pre-existing gap to close opportunistically,
|
||||
not a precedent to extend.
|
||||
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): reflects over the
|
||||
`ErsatzTV.Controllers.Api` namespace to find every concrete, `[ApiController]`-marked
|
||||
controller class — no manual registry to maintain; a new controller is covered
|
||||
automatically. (It intentionally does **not** filter on `ControllerBase`: several API
|
||||
controllers — including the lone exempt `ScannerController` — do not derive from it, and a
|
||||
`ControllerBase` filter would silently drop them.) The test walks every mutating (POST/PUT/PATCH/DELETE) action on each scanned
|
||||
controller and asserts it's covered by the global `ApiKeyAuthorizationFilter` (or has an
|
||||
explicit `[SkipApiKeyAuthorizationAttribute]` exemption; only `ScannerController` is exempt
|
||||
today). A minimum-count guard asserts the scan found a sane number of controllers, so a
|
||||
namespace rename can't silently make the scan match nothing and give this test a false pass.
|
||||
- **Handler tests** (business logic behind the controller) use the shared in-memory SQLite fixture:
|
||||
`ErsatzTV.Tests/Support/InMemoryTvContext.cs`. Pattern: `SqliteConnection("Data Source=:memory:;Foreign
|
||||
Keys=False")` kept open for the fixture's lifetime, `EnsureCreatedAsync()` (not full migration
|
||||
|
||||
+4
-4
@@ -290,7 +290,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group blocks and edit their items, durations, and playout preview.',
|
||||
icon: <Boxes aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Blocks workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -303,7 +303,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group templates and assign blocks to times of day.',
|
||||
icon: <CalendarDays aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -316,7 +316,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
|
||||
icon: <Palette aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Decos workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
@@ -329,7 +329,7 @@ const routes: ScreenRoute[] = [
|
||||
kicker: 'Programming',
|
||||
description: 'Group deco templates and assign decos to times of day.',
|
||||
icon: <CalendarRange aria-hidden="true" size={16} />,
|
||||
primaryAction: 'New Group',
|
||||
primaryAction: '',
|
||||
placeholder: 'Deco templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
|
||||
import {
|
||||
@@ -16,11 +16,8 @@ import {
|
||||
|
||||
const TRAKT_BASE_PATH = '/app/trakt-lists';
|
||||
|
||||
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
|
||||
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
|
||||
function classicSearchUrl(traktId: number): string {
|
||||
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
|
||||
return `/search?${params.toString()}`;
|
||||
function viewMatchedItems(traktId: number) {
|
||||
navigateToPath(`/app/search?query=${encodeURIComponent(`trakt_list:${traktId}`)}`);
|
||||
}
|
||||
|
||||
function traktListIdFromPathname(pathname: string): number | null {
|
||||
@@ -462,15 +459,14 @@ export function TraktListsScreen() {
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<a
|
||||
className="ctv-icon-button ctv-icon-button-sm"
|
||||
href={classicSearchUrl(list.traktId)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View matched items (opens the Classic UI search page)"
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => viewMatchedItems(list.traktId)}
|
||||
size="sm"
|
||||
title="View matched items"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
</a>
|
||||
<Search aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteTarget(list)}
|
||||
|
||||
Reference in New Issue
Block a user