mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
When a file exists in the database as storedInternal but is missing from disk, the error response was incorrectly getting Cache-Control: max-age=31536000, immutable. This was because the handler set immutable cache headers before attempting to stream the file, and by the time the stream encountered the ENOENT error, HTTP headers were already committed. Fix: add fs.promises.access() check in FileServerFileResolver for stored original files before returning the path. If the file is not accessible on disk, the access check throws, propagating to errorHandler which correctly sets Cache-Control: max-age=300. For thumbnail/webpublic stored files, detectType() already performed this check implicitly via fs.stat(). Only original files were affected. This fixes both the /files/:key and /proxy/:url* endpoints since both call resolveFileByAccessKey for local files. Agent-Logs-Url: https://github.com/misskey-dev/misskey/sessions/5dd1c781-b9ad-43cf-b107-79c502d2e602 Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js';
|
|
import { createTemp } from '@/misc/create-temp.js';
|
|
import type { DownloadService } from '@/core/DownloadService.js';
|
|
import type { FileInfoService } from '@/core/FileInfoService.js';
|
|
import type { InternalStorageService } from '@/core/InternalStorageService.js';
|
|
|
|
export type DownloadedFileResult = {
|
|
kind: 'downloaded';
|
|
mime: string;
|
|
ext: string | null;
|
|
path: string;
|
|
cleanup: () => void;
|
|
filename: string;
|
|
};
|
|
|
|
export type FileResolveResult =
|
|
| { kind: 'not-found' }
|
|
| { kind: 'unavailable' }
|
|
| {
|
|
kind: 'stored';
|
|
fileRole: 'thumbnail' | 'webpublic' | 'original';
|
|
file: MiDriveFile;
|
|
filename: string;
|
|
mime: string;
|
|
ext: string | null;
|
|
path: string;
|
|
}
|
|
| {
|
|
kind: 'remote';
|
|
fileRole: 'thumbnail' | 'webpublic' | 'original';
|
|
file: MiDriveFile;
|
|
filename: string;
|
|
url: string;
|
|
mime: string;
|
|
ext: string | null;
|
|
path: string;
|
|
cleanup: () => void;
|
|
};
|
|
|
|
export class FileServerFileResolver {
|
|
constructor(
|
|
private driveFilesRepository: DriveFilesRepository,
|
|
private fileInfoService: FileInfoService,
|
|
private downloadService: DownloadService,
|
|
private internalStorageService: InternalStorageService,
|
|
) {}
|
|
|
|
public async downloadAndDetectTypeFromUrl(url: string): Promise<DownloadedFileResult> {
|
|
const [path, cleanup] = await createTemp();
|
|
try {
|
|
const { filename } = await this.downloadService.downloadUrl(url, path);
|
|
|
|
const { mime, ext } = await this.fileInfoService.detectType(path);
|
|
|
|
return {
|
|
kind: 'downloaded',
|
|
mime, ext,
|
|
path, cleanup,
|
|
filename,
|
|
};
|
|
} catch (e) {
|
|
cleanup();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
public async resolveFileByAccessKey(key: string): Promise<FileResolveResult> {
|
|
// Fetch drive file
|
|
const file = await this.driveFilesRepository.createQueryBuilder('file')
|
|
.where('file.accessKey = :accessKey', { accessKey: key })
|
|
.orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key })
|
|
.orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key })
|
|
.getOne();
|
|
|
|
if (file == null) return { kind: 'not-found' };
|
|
|
|
const isThumbnail = file.thumbnailAccessKey === key;
|
|
const isWebpublic = file.webpublicAccessKey === key;
|
|
|
|
if (!file.storedInternal) {
|
|
if (!(file.isLink && file.uri)) return { kind: 'unavailable' };
|
|
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
|
|
const { kind: _kind, ...downloaded } = result;
|
|
file.size = (await fs.promises.stat(downloaded.path)).size; // DB file.sizeは正確とは限らないので
|
|
return {
|
|
kind: 'remote',
|
|
...downloaded,
|
|
url: file.uri,
|
|
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
|
|
file,
|
|
filename: file.name,
|
|
};
|
|
}
|
|
|
|
const path = this.internalStorageService.resolvePath(key);
|
|
|
|
if (isThumbnail || isWebpublic) {
|
|
const { mime, ext } = await this.fileInfoService.detectType(path);
|
|
return {
|
|
kind: 'stored',
|
|
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
|
|
file,
|
|
filename: file.name,
|
|
mime, ext,
|
|
path,
|
|
};
|
|
}
|
|
|
|
// Verify the file actually exists on disk before returning.
|
|
// Unlike the thumbnail/webpublic cases above where detectType() implicitly
|
|
// checks this, the original file path is returned without an existence check.
|
|
// Without this check, the handler would set immutable cache headers before
|
|
// discovering the file is missing, causing CDN caches to cache the error
|
|
// response for 1 year (max-age=31536000, immutable) instead of max-age=300.
|
|
await fs.promises.access(path);
|
|
|
|
return {
|
|
kind: 'stored',
|
|
fileRole: 'original',
|
|
file,
|
|
filename: file.name,
|
|
// 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる
|
|
mime: this.fileInfoService.fixMime(file.type),
|
|
ext: null,
|
|
path,
|
|
};
|
|
}
|
|
}
|