Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros
e06cedb626 fix: hide download action for local/merged assets (#26461)
* fix: hide download action for local/merged assets

* chore: use onlyRemote

* chore: rename hasLocal to onlyLocal
2026-03-01 11:16:45 +05:30
9 changed files with 11 additions and 158 deletions

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env node
/**
* Computes SHA-256 hashes for inline <script> elements in app.html
* and updates the script-src CSP directive in svelte.config.js.
*
* SvelteKit's CSP hash mode only hashes inline content it generates itself,
* not the template content from app.html. This script fills that gap.
*
* Run this script whenever the inline scripts in app.html change.
*
* Usage: node misc/update-csp-hashes.mjs
*/
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDirectory, '..');
const appHtmlPath = join(repoRoot, 'web', 'src', 'app.html');
const configPath = join(repoRoot, 'web', 'svelte.config.js');
const appHtml = readFileSync(appHtmlPath, 'utf-8');
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/g;
const hashes = [];
let match;
while ((match = scriptRegex.exec(appHtml)) !== null) {
const content = match[1];
const hash = createHash('sha256').update(content).digest('base64');
hashes.push(`sha256-${hash}`);
const preview = content.trim().slice(0, 60).replaceAll('\n', ' ');
console.log(`Found: ${preview}...`);
console.log(` Hash: sha256-${hash}`);
console.log();
}
if (hashes.length === 0) {
console.log('No inline <script> elements found in app.html');
process.exit(0);
}
let config = readFileSync(configPath, 'utf-8');
const scriptSrcRegex = /'script-src':\s*\[[\s\S]*?\]/;
const scriptSrcMatch = config.match(scriptSrcRegex);
if (!scriptSrcMatch) {
console.error("Could not find 'script-src' directive in svelte.config.js");
process.exit(1);
}
const existingEntries = [];
const entryRegex = /'([^']+)'/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(scriptSrcMatch[0])) !== null) {
const value = entryMatch[1];
if (value === 'script-src' || value.startsWith('sha256-')) {
continue;
}
existingEntries.push(value);
}
const allEntries = [...existingEntries, ...hashes];
const formatted = allEntries.map((entry) => ` '${entry}'`).join(',\n');
const newScriptSrc = `'script-src': [\n${formatted},\n ]`;
config = config.replace(scriptSrcRegex, newScriptSrc);
writeFileSync(configPath, config);
console.log(`Updated svelte.config.js with ${hashes.length} script hash(es)`);

View File

@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),

View File

@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),

View File

@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -119,10 +119,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged)
const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [

View File

@@ -24,10 +24,12 @@ class MultiSelectState {
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets,
Set<BaseAsset>? lockedSelectionAssets,

View File

@@ -30,7 +30,6 @@ import { type ServerService as _ServerService } from 'src/services/server.servic
import { type VersionService as _VersionService } from 'src/services/version.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { extractCsp } from 'src/utils/csp';
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -135,9 +134,6 @@ export class MaintenanceWorkerService {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
const { csp, html: indexWithoutCspMeta } = extractCsp(index);
index = indexWithoutCspMeta;
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
@@ -154,9 +150,6 @@ export class MaintenanceWorkerService {
return res.redirect(`${maintenancePath}?${params}`);
}
if (csp) {
res.header('Content-Security-Policy', csp);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}

View File

@@ -9,7 +9,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { augmentCsp, extractCsp } from 'src/utils/csp';
import { OpenGraphTags } from 'src/utils/misc';
export const render = (index: string, meta: OpenGraphTags) => {
@@ -63,12 +62,6 @@ export class ApiService {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
const { csp: baseCsp, html: indexWithoutCspMeta } = extractCsp(index);
index = indexWithoutCspMeta;
const csp = augmentCsp(baseCsp, {
'connect-src': ['wss:', 'https://pay.futo.org', 'https://buy.immich.app'],
});
return async (request: Request, res: Response, next: NextFunction) => {
const method = request.method.toLowerCase();
if (
@@ -112,9 +105,6 @@ export class ApiService {
html = render(index, meta);
}
if (csp) {
res.header('Content-Security-Policy', csp);
}
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
};
}

View File

@@ -1,41 +0,0 @@
const CSP_META_REGEX = /<meta\s+http-equiv="content-security-policy"\s+content="([^"]+)"\s*\/?>/i;
export function extractCsp(html: string): { csp: string; html: string } {
const match = html.match(CSP_META_REGEX);
if (!match) {
return { csp: '', html };
}
return {
csp: match[1],
html: html.replace(match[0], ''),
};
}
export function augmentCsp(baseCsp: string, additions: Record<string, string[]>): string {
if (!baseCsp) {
return '';
}
const directives = new Map<string, Set<string>>();
for (const part of baseCsp.split(';')) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const [directive, ...values] = trimmed.split(/\s+/);
directives.set(directive, new Set(values));
}
for (const [directive, values] of Object.entries(additions)) {
const existing = directives.get(directive) ?? new Set<string>();
for (const value of values) {
existing.add(value);
}
directives.set(directive, existing);
}
return [...directives.entries()].map(([directive, values]) => `${directive} ${[...values].join(' ')}`).join('; ');
}

View File

@@ -22,26 +22,6 @@ const config = {
fallback: 'index.html',
precompress: true,
}),
csp: {
mode: 'hash',
directives: {
'default-src': ['self'],
'script-src': [
'self',
'https://www.gstatic.com',
'wasm-unsafe-eval',
'sha256-h5wSYKWbmHcoYTdkHNNguMswVNCphpvwW+uxooXhF/Y=',
],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:', 'https:'],
'font-src': ['self'],
'connect-src': ['self', 'https:'],
'worker-src': ['self'],
'frame-src': ['none'],
'object-src': ['none'],
'base-uri': ['self'],
},
},
alias: {
$lib: 'src/lib',
'$lib/*': 'src/lib/*',