1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Server: Resolves #5286: Set better filename and mime type for files downloaded via published notes

This commit is contained in:
Laurent Cozic 2021-08-10 19:13:16 +01:00
parent 4b5318c6d0
commit 77cdd3467d
4 changed files with 53 additions and 14 deletions

View File

@ -58,20 +58,27 @@ export function safeFilename(e: string, maxLength: number = null, allowSpaces: b
return output.substr(0, maxLength); return output.substr(0, maxLength);
} }
let friendlySafeFilename_blackListChars = '/<>:\'"\\|?*#'; let friendlySafeFilename_blackListChars = '/\n\r<>:\'"\\|?*#';
for (let i = 0; i < 32; i++) { for (let i = 0; i < 32; i++) {
friendlySafeFilename_blackListChars += String.fromCharCode(i); friendlySafeFilename_blackListChars += String.fromCharCode(i);
} }
const friendlySafeFilename_blackListNames = ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']; const friendlySafeFilename_blackListNames = ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
export function friendlySafeFilename(e: string, maxLength: number = null) { export function friendlySafeFilename(e: string, maxLength: number = null, preserveExtension: boolean = false) {
// Although Windows supports paths up to 255 characters, but that includes the filename and its // Although Windows supports paths up to 255 characters, but that includes the filename and its
// parent directory path. Also there's generally no good reason for dir or file names // parent directory path. Also there's generally no good reason for dir or file names
// to be so long, so keep it at 50, which should prevent various errors. // to be so long, so keep it at 50, which should prevent various errors.
if (maxLength === null) maxLength = 50; if (maxLength === null) maxLength = 50;
if (!e || !e.replace) return _('Untitled'); if (!e || !e.replace) return _('Untitled');
let fileExt = '';
if (preserveExtension) {
fileExt = `.${safeFileExtension(fileExtension(e))}`;
e = filename(e);
}
let output = ''; let output = '';
for (let i = 0; i < e.length; i++) { for (let i = 0; i < e.length; i++) {
const c = e[i]; const c = e[i];
@ -106,9 +113,9 @@ export function friendlySafeFilename(e: string, maxLength: number = null) {
} }
} }
if (!output) return _('Untitled'); if (!output) return _('Untitled') + fileExt;
return output.substr(0, maxLength); return output.substr(0, maxLength) + fileExt;
} }
export function toFileProtocolPath(filePathEncode: string, os: string = null) { export function toFileProtocolPath(filePathEncode: string, os: string = null) {

View File

@ -15,6 +15,7 @@ describe('pathUtils', function() {
['no space at the end ', 'no space at the end'], ['no space at the end ', 'no space at the end'],
['nor dots...', 'nor dots'], ['nor dots...', 'nor dots'],
[' no space before either', 'no space before either'], [' no space before either', 'no space before either'],
['no\nnewline\n\rplease', 'no_newline__please'],
['thatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylong', 'thatsreallylongthatsreallylongthatsreallylongthats'], ['thatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylong', 'thatsreallylongthatsreallylongthatsreallylongthats'],
]; ];
@ -25,6 +26,11 @@ describe('pathUtils', function() {
expect(!!friendlySafeFilename('')).toBe(true); expect(!!friendlySafeFilename('')).toBe(true);
expect(!!friendlySafeFilename('...')).toBe(true); expect(!!friendlySafeFilename('...')).toBe(true);
// Check that it optionally handles filenames with extension
expect(friendlySafeFilename(' testing.md', null, true)).toBe('testing.md');
expect(friendlySafeFilename('testing.safe??ext##', null, true)).toBe('testing.safeext');
expect(friendlySafeFilename('thatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylong.md', null, true)).toBe('thatsreallylongthatsreallylongthatsreallylongthats.md');
})); }));
it('should quote and unquote paths', (async () => { it('should quote and unquote paths', (async () => {

View File

@ -6,6 +6,7 @@ import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
import { Item, Share } from '../../db'; import { Item, Share } from '../../db';
import { ModelType } from '@joplin/lib/BaseModel'; import { ModelType } from '@joplin/lib/BaseModel';
import { FileViewerResponse, renderItem as renderJoplinItem } from '../../utils/joplinUtils'; import { FileViewerResponse, renderItem as renderJoplinItem } from '../../utils/joplinUtils';
import { friendlySafeFilename } from '@joplin/lib/path-utils';
async function renderItem(context: AppContext, item: Item, share: Share): Promise<FileViewerResponse> { async function renderItem(context: AppContext, item: Item, share: Share): Promise<FileViewerResponse> {
if (item.jop_type === ModelType.Note) { if (item.jop_type === ModelType.Note) {
@ -16,6 +17,7 @@ async function renderItem(context: AppContext, item: Item, share: Share): Promis
body: item.content, body: item.content,
mime: item.mime_type, mime: item.mime_type,
size: item.content_size, size: item.content_size,
filename: '',
}; };
} }
@ -44,6 +46,7 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
ctx.response.body = result.body; ctx.response.body = result.body;
ctx.response.set('Content-Type', result.mime); ctx.response.set('Content-Type', result.mime);
ctx.response.set('Content-Length', result.size.toString()); ctx.response.set('Content-Length', result.size.toString());
if (result.filename) ctx.response.set('Content-disposition', `attachment; filename="${friendlySafeFilename(result.filename)}"`);
return new Response(ResponseType.KoaResponse, ctx.response); return new Response(ResponseType.KoaResponse, ctx.response);
}, RouteType.UserContent); }, RouteType.UserContent);

View File

@ -24,13 +24,15 @@ import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { Models } from '../models/factory'; import { Models } from '../models/factory';
import MustacheService from '../services/MustacheService'; import MustacheService from '../services/MustacheService';
import Logger from '@joplin/lib/Logger';
// const logger = Logger.create('JoplinUtils'); const logger = Logger.create('JoplinUtils');
export interface FileViewerResponse { export interface FileViewerResponse {
body: any; body: any;
mime: string; mime: string;
size: number; size: number;
filename: string;
} }
interface ResourceInfo { interface ResourceInfo {
@ -153,11 +155,25 @@ async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, note: Not
return output; return output;
} }
async function renderResource(item: Item, content: any): Promise<FileViewerResponse> { async function renderResource(userId: string, resourceId: string, item: Item, content: any): Promise<FileViewerResponse> {
// The item passed to this function is the resource blob, which is
// sufficient to download the resource. However, if we want a more user
// friendly download, we need to know the resource original name and mime
// type. So below, we try to get that information.
let jopItem: any = null;
try {
const resourceItem = await models_.item().loadByJopId(userId, resourceId);
jopItem = await models_.item().loadAsJoplinItem(resourceItem.id);
} catch (error) {
logger.error(`Could not load Joplin item ${resourceId} associated with item: ${item.id}`);
}
return { return {
body: content, body: content,
mime: item.mime_type, mime: jopItem ? jopItem.mime : item.mime_type,
size: item.content_size, size: item.content_size,
filename: jopItem ? jopItem.title : '',
}; };
} }
@ -219,6 +235,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
body: bodyHtml, body: bodyHtml,
mime: 'text/html', mime: 'text/html',
size: Buffer.byteLength(bodyHtml, 'utf-8'), size: Buffer.byteLength(bodyHtml, 'utf-8'),
filename: '',
}; };
} }
@ -234,28 +251,34 @@ export async function renderItem(userId: Uuid, item: Item, share: Share, query:
const linkedItemInfos: LinkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote); const linkedItemInfos: LinkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote);
const resourceInfos = await getResourceInfos(linkedItemInfos); const resourceInfos = await getResourceInfos(linkedItemInfos);
const fileToRender = { interface FileToRender {
item: Item;
content: any;
jopItemId: string;
}
const fileToRender: FileToRender = {
item: item, item: item,
content: null as any, content: null as any,
itemId: rootNote.id, jopItemId: rootNote.id,
}; };
if (query.resource_id) { if (query.resource_id) {
const resourceItem = await models_.item().loadByName(userId, resourceBlobPath(query.resource_id), { fields: ['*'] }); const resourceItem = await models_.item().loadByName(userId, resourceBlobPath(query.resource_id), { fields: ['*'] });
fileToRender.item = resourceItem; fileToRender.item = resourceItem;
fileToRender.content = resourceItem.content; fileToRender.content = resourceItem.content;
fileToRender.itemId = query.resource_id; fileToRender.jopItemId = query.resource_id;
} }
if (fileToRender.item !== item && !linkedItemInfos[fileToRender.itemId]) { if (fileToRender.item !== item && !linkedItemInfos[fileToRender.jopItemId]) {
throw new ErrorNotFound(`Item "${fileToRender.itemId}" does not belong to this note`); throw new ErrorNotFound(`Item "${fileToRender.jopItemId}" does not belong to this note`);
} }
const itemToRender = fileToRender.item === item ? rootNote : linkedItemInfos[fileToRender.itemId].item; const itemToRender = fileToRender.item === item ? rootNote : linkedItemInfos[fileToRender.jopItemId].item;
const itemType: ModelType = itemToRender.type_; const itemType: ModelType = itemToRender.type_;
if (itemType === ModelType.Resource) { if (itemType === ModelType.Resource) {
return renderResource(fileToRender.item, fileToRender.content); return renderResource(userId, fileToRender.jopItemId, fileToRender.item, fileToRender.content);
} else if (itemType === ModelType.Note) { } else if (itemType === ModelType.Note) {
return renderNote(share, itemToRender, resourceInfos, linkedItemInfos); return renderNote(share, itemToRender, resourceInfos, linkedItemInfos);
} else { } else {