Compare commits

..

1 Commits

Author SHA1 Message Date
Gauvino
06510d2bd6 chore(security): harden helpers + document conflict-labeler safety
From the workflow security audit:
- symlink-native-dirs.js: drop the execSync shell strings for fs.symlink/mkdir
  (removes a latent shell-injection surface; also clears dead commented code).
- automerge.sh: add 'set -euo pipefail' and restore the starting branch on exit
  so a mid-merge failure can't leave the repo on the wrong branch.
- conflict.yml: document that this pull_request_target workflow must never check
  out or run PR-head code (it only labels via the API today).
2026-06-01 20:35:05 +02:00
8 changed files with 87 additions and 112 deletions

View File

@@ -3,6 +3,11 @@ name: 🏷🔀Merge Conflict Labeler
on: on:
push: push:
branches: [develop] branches: [develop]
# SECURITY: pull_request_target runs with the base repo's write token and secrets.
# This job only labels via the API and is safe ONLY because it never checks out or
# runs the PR head's code. NEVER add `actions/checkout` of the PR head (or any `run:`
# that interpolates PR-controlled data) to this workflow — that would turn it into a
# full repo-compromise vector.
pull_request_target: pull_request_target:
branches: [develop] branches: [develop]
types: [synchronize] types: [synchronize]

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen} open={dropdownOpen}
onOpenChange={setDropdownOpen} onOpenChange={setDropdownOpen}
trigger={ trigger={
<View> <View className='pl-1.5'>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}

View File

@@ -133,6 +133,7 @@ const HomeMobile = () => {
onPress={() => { onPress={() => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather <Feather

View File

@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) { if (loading) {
return null; return null;
} }
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
return ( return (
<View> <View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection <TVJellyseerrMovieSection
title={t("search.request_movies")} title={t("search.request_movies")}
items={movieResults} items={movieResults}
isFirstSection={false} isFirstSection={hasMovies}
onItemPress={onMoviePress} onItemPress={onMoviePress}
/> />
<TVJellyseerrTvSection <TVJellyseerrTvSection
title={t("search.request_series")} title={t("search.request_series")}
items={tvResults} items={tvResults}
isFirstSection={false} isFirstSection={!hasMovies && hasTv}
onItemPress={onTvPress} onItemPress={onTvPress}
/> />
<TVJellyseerrPersonSection <TVJellyseerrPersonSection
title={t("search.actors")} title={t("search.actors")}
items={personResults} items={personResults}
isFirstSection={false} isFirstSection={!hasMovies && !hasTv && hasPersons}
onItemPress={onPersonPress} onItemPress={onPersonPress}
/> />
</View> </View>

View File

@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch; forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */} our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View <View
style={{ style={{
marginBottom: 24, marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT, height: SEARCH_AREA_HEIGHT,
}} }}
> >
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */} {/* Library Search Results */}
{isLibraryMode && !loading && ( {isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}> <View style={{ gap: SECTION_GAP }}>
{sections.map((section) => ( {sections.map((section, index) => (
<TVSearchSection <TVSearchSection
key={section.key} key={section.key}
title={section.title} title={section.title}
items={section.items!} items={section.items!}
orientation={section.orientation || "vertical"} orientation={section.orientation || "vertical"}
// Never auto-focus a result. The native search field owns focus isFirstSection={index === 0}
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={getItemLayout} getItemLayout={getItemLayout}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset. contentInset={{
// contentOffset only applies on initial mount; since this FlatList is left: edgePadding,
// reused across searches (stable key), a second search left the inset right: edgePadding,
// without the offset and the grid snapped flush to the left edge. }}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING, paddingVertical: SCALE_PADDING,
}} }}
/> />

View File

@@ -1,12 +1,22 @@
#!/bin/bash #!/bin/bash
[[ -z $(git status --porcelain) ]] && # Local helper: fast-forward master into develop and back. Aborts on any failure and
git checkout master && # restores the branch you started on. Not used in CI.
git pull --ff-only && set -euo pipefail
git checkout develop &&
git merge master && if [[ -n $(git status --porcelain) ]]; then
git push --follow-tags && echo "Error: working tree is not clean — commit or stash first." >&2
git checkout master && exit 1
git merge develop --ff-only && fi
git push &&
git checkout develop || start_branch=$(git rev-parse --abbrev-ref HEAD)
(echo "Error: Failed to merge" && exit 1) trap 'git checkout "$start_branch" >/dev/null 2>&1 || true' EXIT
git checkout master
git pull --ff-only
git checkout develop
git merge master
git push --follow-tags
git checkout master
git merge develop --ff-only
git push
git checkout develop

View File

@@ -1,62 +1,28 @@
#!/usr/bin/env node #!/usr/bin/env node
const _fs = require("node:fs"); // Symlinks the platform-specific native dirs to `ios` / `android` depending on EXPO_TV.
// Uses fs APIs (no shell) so there is no command-injection surface.
const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd(); const root = process.cwd();
// const tvosPath = path.join(root, 'iostv'); const isTV = process.env.EXPO_TV && process.env.EXPO_TV !== "0";
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([ const links = isTV
["tvos", path.join(root, "iostv")], ? { ios: path.join(root, "iostv"), android: path.join(root, "androidtv") }
["ios", path.join(root, "iosmobile")], : {
["android", path.join(root, "androidmobile")], ios: path.join(root, "iosmobile"),
["androidtv", path.join(root, "androidtv")], android: path.join(root, "androidmobile"),
]); };
// const platformPath = paths.get(platform); for (const [link, target] of Object.entries(links)) {
fs.mkdirSync(target, { recursive: true });
if (isTV) { try {
stdout = execSync( fs.unlinkSync(link); // replace an existing symlink/file (ln -nsf)
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`, } catch {
); // nothing to remove
console.log(stdout.toString()); }
stdout = execSync( fs.symlinkSync(target, link);
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get( console.log(`${link} -> ${target}`);
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
} }
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }