Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ per-server secret must follow:
- `services/api/client.ts` reads it back off the in-memory `ServerConfig` to
build the header via `utils/basicAuth.ts`.

Custom headers (`useCustomHeaders`, #228) follow the same rule: the flag is
plain, but the whole `customHeaders` array is a secret (values are tokens),
JSON-stringified into `server_custom_headers_{id}` and forced to `[]` in
AsyncStorage.

Be deliberate whenever you touch code that persists a `ServerConfig` — including
paths that rewrite the *whole* server list, such as add, edit, delete, and
import. A secret must never reach AsyncStorage, and bulk rewrites are the easiest
Expand Down Expand Up @@ -379,7 +384,9 @@ All PascalCase function components taking a `…Props` interface.
`avatarColor(name)`), `ServerAppearanceSection` (icon + badge-color editor
used by both `app/server/add.tsx` and `app/server/[id].tsx` — quick
`AVATAR_PALETTE` swatches plus a "custom color" swatch that opens the full
`ColorPicker`).
`ColorPicker`), `CustomHeadersSection` (per-server custom HTTP header
key/value rows, max 5, used by the same two screens — see
`utils/customHeaders.ts`).
- **Visuals** — `SpeedGraph`, `CircularProgress`, `AnimatedProgressBar`,
`AnimatedButton`, `Confetti`.
- **Chrome / diagnostics** — `FocusAwareStatusBar`, `SettingRow`,
Expand Down Expand Up @@ -461,6 +468,9 @@ label, completion and ETA rules) · `limit-input.ts` (share-limit sentinels:
`server.ts` (endpoint resolution incl. fallback URL, avatar colors, and
`getServerIcon`/`getServerIconColor` for the per-server badge — #224) ·
`authMode.ts` (derives `password`/`apiKey`/`none`) · `basicAuth.ts` ·
`customHeaders.ts` (per-server custom HTTP headers — sanitize/validate, and the
reserved-name set the app manages itself: Authorization, Cookie, Referer,
Origin, Content-Type, Host — #228) ·
`magnet.ts` / `torrent-file.ts` (incoming link and file parsing) · `rss.ts`
(RSS tree flattening; paths join with `\`) · `searchResult.ts` (indexer-label
heuristics) · `login-response.ts` (qBittorrent login body/cookie interpretation) ·
Expand Down
2 changes: 2 additions & 0 deletions app/(tabs)/settings/advanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export default function AdvancedSettingsScreen() {
password: '',
basicAuthPassword: '',
apiKey: '',
// Imports never carry secrets — same rule as utils/server-export.ts.
customHeaders: [],
});
}
}
Expand Down
84 changes: 83 additions & 1 deletion app/server/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ import { DebugRow } from '@/components/DebugRow';
import { SettingRow } from '@/components/SettingRow';
import { OptionPicker, OptionPickerItem } from '@/components/OptionPicker';
import { ServerAppearanceSection } from '@/components/ServerAppearanceSection';
import { CustomHeadersSection } from '@/components/CustomHeadersSection';
import { spacing, borderRadius } from '@/constants/spacing';
import { shadows } from '@/constants/shadows';
import * as Clipboard from 'expo-clipboard';
import { APP_VERSION } from '@/utils/version';
import { getErrorMessage } from '@/utils/error';
import { ServerAuthMode, getServerAuthMode, applyServerAuthMode } from '@/utils/authMode';
import {
CustomHeaderPair,
sanitizeCustomHeaders,
validateCustomHeaders,
} from '@/utils/customHeaders';

export default function EditServerScreen() {
const router = useRouter();
Expand All @@ -63,6 +69,8 @@ export default function EditServerScreen() {
const [useBasicAuth, setUseBasicAuth] = useState(false);
const [basicAuthUsername, setBasicAuthUsername] = useState('');
const [basicAuthPassword, setBasicAuthPassword] = useState('');
const [useCustomHeaders, setUseCustomHeaders] = useState(false);
const [customHeaders, setCustomHeaders] = useState<CustomHeaderPair[]>([]);
const [useFallback, setUseFallback] = useState(false);
const [fallbackHost, setFallbackHost] = useState('');
const [fallbackPort, setFallbackPort] = useState('');
Expand Down Expand Up @@ -184,11 +192,16 @@ export default function EditServerScreen() {
baseUrl,
loginEndpoint: `${baseUrl}/api/v2/auth/login`,
versionEndpoint: `${baseUrl}/api/v2/app/version`,
// Names only — this whole block is copied to the clipboard and routinely
// pasted into public issue reports, and header values are auth tokens.
customHeaderNames: useCustomHeaders
? sanitizeCustomHeaders(customHeaders).map((header) => header.key)
: [],
warnings,
hasErrors: warnings.some((w) => w.type === 'error'),
hasWarnings: warnings.some((w) => w.type === 'warning'),
};
}, [host, port, useHttps, authMode, username, password, apiKey]);
}, [host, port, useHttps, authMode, username, password, apiKey, useCustomHeaders, customHeaders]);

// Copy debug info to clipboard
const copyDebugInfo = async () => {
Expand All @@ -199,6 +212,7 @@ Host: ${debugInfo.cleanHost || '(empty)'}
Port: ${debugInfo.portNum || 'default (80/443)'}
HTTPS: ${useHttps ? 'Yes' : 'No'}
Auth Method: ${authMode}
Custom Headers: ${debugInfo.customHeaderNames.length > 0 ? debugInfo.customHeaderNames.join(', ') + ' (values hidden)' : 'None'}

Login Endpoint: ${debugInfo.loginEndpoint}
Version Endpoint: ${debugInfo.versionEndpoint}
Expand Down Expand Up @@ -240,6 +254,8 @@ App Version: ${APP_VERSION}`;
setUseBasicAuth(server.useBasicAuth || false);
setBasicAuthUsername(server.basicAuthUsername || '');
setBasicAuthPassword(server.basicAuthPassword || '');
setUseCustomHeaders(server.useCustomHeaders || false);
setCustomHeaders(server.customHeaders || []);
setIcon(server.icon || '');
setIconColor(server.iconColor || '');
// Preserve existing basePath for backward compatibility
Expand Down Expand Up @@ -290,6 +306,21 @@ App Version: ${APP_VERSION}`;
return;
}

if (useCustomHeaders) {
const headerValidation = validateCustomHeaders(customHeaders);
if (!headerValidation.valid) {
if (headerValidation.error === 'reserved') {
showToast(
t('errors.reservedHeaderName', { name: headerValidation.reservedName }),
'error',
);
} else {
showToast(t('errors.fillCustomHeaderFields'), 'error');
}
return;
}
}

const portNum = port.trim() ? parseInt(port, 10) : undefined;
if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) {
showToast(t('errors.validPort'), 'error');
Expand Down Expand Up @@ -332,6 +363,8 @@ App Version: ${APP_VERSION}`;
useBasicAuth: useProxyBasicAuth,
basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '',
basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '',
useCustomHeaders,
customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [],
useFallback,
fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
fallbackPort: useFallback ? fallbackPortNum : undefined,
Expand Down Expand Up @@ -401,6 +434,21 @@ App Version: ${APP_VERSION}`;
return;
}

if (useCustomHeaders) {
const headerValidation = validateCustomHeaders(customHeaders);
if (!headerValidation.valid) {
if (headerValidation.error === 'reserved') {
showToast(
t('errors.reservedHeaderName', { name: headerValidation.reservedName }),
'error',
);
} else {
showToast(t('errors.fillCustomHeaderFields'), 'error');
}
return;
}
}

const portNum = port.trim() ? parseInt(port, 10) : undefined;
if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) {
showToast(t('errors.validPort'), 'error');
Expand Down Expand Up @@ -442,6 +490,8 @@ App Version: ${APP_VERSION}`;
useBasicAuth: useProxyBasicAuth,
basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '',
basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '',
useCustomHeaders,
customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [],
useFallback,
fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '',
fallbackPort: useFallback ? fallbackPortNum : undefined,
Expand Down Expand Up @@ -711,6 +761,8 @@ App Version: ${APP_VERSION}`;
<Text
style={[styles.authMethodValueText, { color: colors.textSecondary }]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.75}
>
{authMethodLabel}
</Text>
Expand Down Expand Up @@ -933,6 +985,13 @@ App Version: ${APP_VERSION}`;
</View>
</View>

<CustomHeadersSection
useCustomHeaders={useCustomHeaders}
headers={customHeaders}
onUseCustomHeadersChange={setUseCustomHeaders}
onHeadersChange={setCustomHeaders}
/>

{/* Test Connection */}
<View style={styles.section}>
<Text style={[styles.sectionHeader, { color: colors.textSecondary }]}>
Expand Down Expand Up @@ -1016,6 +1075,17 @@ App Version: ${APP_VERSION}`;
debugInfo.portNum ? String(debugInfo.portNum) : t('server.debugDefaultPort')
}
/>
<DebugRow
label={t('server.debugCustomHeaders')}
value={
debugInfo.customHeaderNames.length > 0
? t('server.debugCustomHeadersValue', {
names: debugInfo.customHeaderNames.join(', '),
})
: t('server.debugCustomHeadersNone')
}
numberOfLines={2}
/>

<View style={[styles.separator, { backgroundColor: colors.surfaceOutline }]} />

Expand Down Expand Up @@ -1097,6 +1167,8 @@ App Version: ${APP_VERSION}`;
useBasicAuth={authMode !== 'apiKey' && useBasicAuth}
basicAuthUsername={basicAuthUsername}
basicAuthPassword={basicAuthPassword}
useCustomHeaders={useCustomHeaders}
customHeaders={customHeaders}
/>
</View>
)}
Expand Down Expand Up @@ -1361,10 +1433,20 @@ const styles = StyleSheet.create({
authMethodValue: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 4,
// SettingRow's label side is flex:1, so it only gets what's left over after
// this value is laid out at its intrinsic width. Unbounded, a long value
// ("Username & Password") starved the label until "Authentication Method"
// broke mid-word. Capping the value reserves enough room for the label to
// wrap on a word boundary; the value scales its font down to compensate.
maxWidth: '45%',
flexShrink: 1,
},
authMethodValueText: {
fontSize: 16,
flexShrink: 1,
textAlign: 'right',
},
hintText: {
fontSize: 12,
Expand Down
Loading
Loading