mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-11 19:16:16 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a55d175a | ||
|
|
54a645bfec | ||
|
|
34696bc462 | ||
|
|
dfe4a2424e | ||
|
|
fbf73e5f6a |
No files matched your search
@@ -1839,6 +1839,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/glyphLessFont.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
|
||||
@@ -1865,6 +1865,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/glyphLessFont.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
|
||||
@@ -397,6 +397,33 @@ describe('OcrService', () => {
|
||||
expect(pdfBytes.length).toBeGreaterThan(jpegBuffer.length * 1.5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ words: ['ı', 'Мир', '日本語'], rtl: false },
|
||||
{ words: ['אור', 'חדש'], rtl: true },
|
||||
{ words: ['نور', 'باب', 'عام2024'], rtl: true },
|
||||
])('should create an accessible PDF with text that can be extracted: $words', async ({ words, rtl }) => {
|
||||
const jpegBuffer = await fs.readFile(`${supportDir}/photo.jpg`);
|
||||
|
||||
// Like Tesseract, words are in logical order, so right-to-left words are laid out from the right
|
||||
const ocrDetails: PdfOcrDetails = {
|
||||
version: 1,
|
||||
pages: [{
|
||||
lines: [{ words: words.map((t, i) => {
|
||||
const x = rtl ? 700 - (i + 1) * 150 : 20 + i * 150;
|
||||
return { t, bb: [x, x + 130, 20, 60] };
|
||||
}) }],
|
||||
}],
|
||||
};
|
||||
|
||||
const pdfBytes = await createAccessiblePdf([{ buffer: jpegBuffer, width: 800, height: 400 }], JSON.stringify(ocrDetails));
|
||||
const pdfPath = join(Setting.value('tempDir'), 'accessible.pdf');
|
||||
await fs.writeFile(pdfPath, pdfBytes);
|
||||
|
||||
const textByPage = await shim.pdfExtractEmbeddedText(pdfPath);
|
||||
expect(textByPage.length).toBe(1);
|
||||
expect(textByPage[0].split(/\s+/)).toEqual(words);
|
||||
});
|
||||
|
||||
it('should truncate long OCR text', async () => {
|
||||
const service = newOcrService();
|
||||
service.testing_setOcrMaxSize(10);
|
||||
|
||||
@@ -1,60 +1,188 @@
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { beginText, endText, PDFDocument, PDFHexString, PDFOperator, PDFPage, PDFRef, PDFString, setCharacterSqueeze, setFontAndSize, setTextMatrix, setTextRenderingMode, showText, TextRenderingMode } from 'pdf-lib';
|
||||
import { PdfOcrDetails, RecognizeResultLine } from './types';
|
||||
import glyphLessFontBase64 from './glyphLessFont';
|
||||
|
||||
// The PDF OCR images are created at 2x scale by pdfToImages()
|
||||
const OCR_SCALE_FACTOR = 2;
|
||||
|
||||
// Metrics of the glyphless font, in 1/1000 of the font size
|
||||
const GLYPH_WIDTH = 500;
|
||||
const FONT_ASCENT = 800;
|
||||
const FONT_DESCENT = -200;
|
||||
|
||||
export interface PageImageWithDimensions {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Maps each UTF-16 code unit (used as CID) to itself. A bfrange may only vary in its last byte and a
|
||||
// block may contain at most 100 entries.
|
||||
const makeToUnicodeCMap = () => {
|
||||
const ranges: string[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const high = i.toString(16).toUpperCase().padStart(2, '0');
|
||||
ranges.push(`<${high}00> <${high}FF> <${high}00>`);
|
||||
}
|
||||
|
||||
// cSpell:disable
|
||||
const blocks: string[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 100) {
|
||||
const chunk = ranges.slice(i, i + 100);
|
||||
blocks.push(`${chunk.length} beginbfrange\n${chunk.join('\n')}\nendbfrange`);
|
||||
}
|
||||
|
||||
return [
|
||||
'/CIDInit /ProcSet findresource begin',
|
||||
'12 dict begin',
|
||||
'begincmap',
|
||||
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
|
||||
'/CMapName /Adobe-Identity-UCS def',
|
||||
'/CMapType 2 def',
|
||||
'1 begincodespacerange',
|
||||
'<0000> <FFFF>',
|
||||
'endcodespacerange',
|
||||
...blocks,
|
||||
'endcmap',
|
||||
'CMapName currentdict /CMap defineresource pop',
|
||||
'end',
|
||||
'end',
|
||||
].join('\n');
|
||||
// cSpell:enable
|
||||
};
|
||||
|
||||
// Standard fonts only support WinAnsi, so any other character (Cyrillic, CJK, etc.) would make
|
||||
// pdf-lib throw. Instead, like Tesseract, we use a Type0 font whose CIDs are UTF-16 code units, all
|
||||
// mapped to the same empty glyph, with a ToUnicode CMap so that the text can be extracted.
|
||||
const embedGlyphLessFont = (pdfDoc: PDFDocument) => {
|
||||
const context = pdfDoc.context;
|
||||
|
||||
const fontFile = Buffer.from(glyphLessFontBase64, 'base64');
|
||||
const fontFileRef = context.register(context.flateStream(fontFile, { Length1: fontFile.length }));
|
||||
|
||||
// Two bytes per CID, all pointing to glyph 1
|
||||
const cidToGidMap = new Uint8Array(0x10000 * 2);
|
||||
for (let i = 1; i < cidToGidMap.length; i += 2) cidToGidMap[i] = 1;
|
||||
const cidToGidMapRef = context.register(context.flateStream(cidToGidMap));
|
||||
|
||||
const fontDescriptorRef = context.register(context.obj({
|
||||
Type: 'FontDescriptor',
|
||||
FontName: 'GlyphLessFont',
|
||||
Flags: 5,
|
||||
FontBBox: [0, FONT_DESCENT, GLYPH_WIDTH, FONT_ASCENT],
|
||||
ItalicAngle: 0,
|
||||
Ascent: FONT_ASCENT,
|
||||
Descent: FONT_DESCENT,
|
||||
CapHeight: FONT_ASCENT,
|
||||
StemV: 80,
|
||||
FontFile2: fontFileRef,
|
||||
}));
|
||||
|
||||
const cidFontRef = context.register(context.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'CIDFontType2',
|
||||
BaseFont: 'GlyphLessFont',
|
||||
CIDSystemInfo: {
|
||||
Registry: PDFString.of('Adobe'),
|
||||
Ordering: PDFString.of('Identity'),
|
||||
Supplement: 0,
|
||||
},
|
||||
FontDescriptor: fontDescriptorRef,
|
||||
CIDToGIDMap: cidToGidMapRef,
|
||||
DW: GLYPH_WIDTH,
|
||||
}));
|
||||
|
||||
const toUnicodeRef = context.register(context.flateStream(makeToUnicodeCMap()));
|
||||
|
||||
return context.register(context.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'Type0',
|
||||
BaseFont: 'GlyphLessFont',
|
||||
Encoding: 'Identity-H',
|
||||
DescendantFonts: [cidFontRef],
|
||||
ToUnicode: toUnicodeRef,
|
||||
}));
|
||||
};
|
||||
|
||||
const rtlScriptRegex = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
|
||||
const isRtlLetter = (char: string) => rtlScriptRegex.test(char) && !/\p{N}/u.test(char);
|
||||
const isLtrStrong = (char: string) => /[\p{L}\p{N}]/u.test(char) && !isRtlLetter(char);
|
||||
|
||||
// OCR text is in logical order, but viewers expect PDF text in visual (left-to-right) order and
|
||||
// reorder it when extracting. So in right-to-left words, the runs are reversed, as well as the
|
||||
// characters within right-to-left runs (numbers and Latin text remain left-to-right).
|
||||
const toVisualOrder = (text: string) => {
|
||||
const chars = Array.from(text);
|
||||
if (!chars.some(isRtlLetter)) return text;
|
||||
|
||||
const runs: string[][] = [];
|
||||
for (const char of chars) {
|
||||
const lastRun = runs[runs.length - 1];
|
||||
if (lastRun && isLtrStrong(lastRun[0]) === isLtrStrong(char)) {
|
||||
lastRun.push(char);
|
||||
} else {
|
||||
runs.push([char]);
|
||||
}
|
||||
}
|
||||
|
||||
return runs.reverse().map(run => isLtrStrong(run[0]) ? run.join('') : run.reverse().join('')).join('');
|
||||
};
|
||||
|
||||
const toUtf16Hex = (text: string) => {
|
||||
let output = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
output += text.charCodeAt(i).toString(16).padStart(4, '0');
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// Adds an invisible text layer to a PDF page based on OCR word positions.
|
||||
// The text is rendered in "invisible" mode (render mode 3) so it doesn't
|
||||
// appear visually but can be selected, copied, and read by screen readers.
|
||||
const addInvisibleTextLayer = (
|
||||
page: PDFPage,
|
||||
lines: RecognizeResultLine[],
|
||||
font: PDFFont,
|
||||
_pageWidth: number,
|
||||
fontRef: PDFRef,
|
||||
pageHeight: number,
|
||||
): void => {
|
||||
) => {
|
||||
const fontName = page.node.newFontDictionary('GlyphLessFont', fontRef);
|
||||
const operators: PDFOperator[] = [beginText(), setTextRenderingMode(TextRenderingMode.Invisible)];
|
||||
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
const text = word.t;
|
||||
if (!text || !text.trim()) continue;
|
||||
|
||||
// Bounding box format from Tesseract: [x0, x1, y0, y1]
|
||||
const [x0, , y0, y1] = word.bb;
|
||||
const [x0, x1, y0, y1] = word.bb;
|
||||
|
||||
// Convert from OCR coordinates (2x scale, origin top-left)
|
||||
// to PDF coordinates (1x scale, origin bottom-left)
|
||||
const pdfX = x0 / OCR_SCALE_FACTOR;
|
||||
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR); // Flip Y axis and use bottom of bbox
|
||||
|
||||
// Calculate word height in PDF coordinates (width not currently used)
|
||||
const wordWidth = (x1 - x0) / OCR_SCALE_FACTOR;
|
||||
const wordHeight = (y1 - y0) / OCR_SCALE_FACTOR;
|
||||
|
||||
// Estimate font size based on word height
|
||||
// We want the text to fit within the bounding box
|
||||
const fontSize = Math.max(1, wordHeight * 0.85);
|
||||
// The font's ascent-to-descent height is one em, so this makes the glyphs span the word's
|
||||
// bounding box, with the baseline placed above its bottom by the descent.
|
||||
const fontSize = Math.max(1, wordHeight);
|
||||
const pdfX = x0 / OCR_SCALE_FACTOR;
|
||||
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR) - fontSize * FONT_DESCENT / 1000;
|
||||
|
||||
// Draw the text as invisible (using transparent color)
|
||||
// PDF.js and screen readers can still detect and read this text
|
||||
page.drawText(text, {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
size: fontSize,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0, // Make text invisible
|
||||
// Note: pdf-lib doesn't directly support render mode 3 (invisible),
|
||||
// but opacity: 0 achieves the same visual effect while keeping
|
||||
// the text selectable
|
||||
});
|
||||
// Stretch the text horizontally so that its selection matches the word on the image
|
||||
const hex = toUtf16Hex(toVisualOrder(text));
|
||||
const naturalWidth = (hex.length / 4) * fontSize * GLYPH_WIDTH / 1000;
|
||||
const horizontalScale = wordWidth > 0 ? wordWidth / naturalWidth * 100 : 100;
|
||||
|
||||
operators.push(
|
||||
setFontAndSize(fontName, fontSize),
|
||||
setCharacterSqueeze(horizontalScale),
|
||||
setTextMatrix(1, 0, 0, 1, pdfX, pdfY),
|
||||
showText(PDFHexString.of(hex)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
operators.push(endText());
|
||||
page.pushOperators(...operators);
|
||||
};
|
||||
|
||||
// Creates an accessible PDF by overlaying invisible text on top of page images.
|
||||
@@ -78,8 +206,7 @@ const createAccessiblePdf = async (
|
||||
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Embed a standard font for the invisible text layer
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontRef = embedGlyphLessFont(pdfDoc);
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pageImages.length; pageIndex++) {
|
||||
const pageImage = pageImages[pageIndex];
|
||||
@@ -104,7 +231,7 @@ const createAccessiblePdf = async (
|
||||
});
|
||||
|
||||
// Add invisible text layer on top
|
||||
addInvisibleTextLayer(page, pageOcr.lines, font, pageWidth, pageHeight);
|
||||
addInvisibleTextLayer(page, pageOcr.lines, fontRef, pageHeight);
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Minimal TrueType font generated with fontTools' FontBuilder. Same idea as Tesseract's GlyphLessFont,
|
||||
// used for invisible OCR text. Glyph 1 is a 500x1000 rectangle, with ascent 800 and descent -200.
|
||||
// It's never drawn (render mode 3), but macOS PDFKit (Preview) needs a non-zero descent and a glyph
|
||||
// outline to compute text bounds, otherwise the text can't be selected.
|
||||
const glyphLessFontBase64 = 'AAEAAAAKAIAAAwAgT1MvMkUAQ7AAAAEoAAAAYGNtYXAADABzAAABkAAAADRnbHlmA24B+AAAAcwAAAAaaGVhZC9AAeoAAACsAAAANmhoZWEFFgEuAAAA5AAAACRobXR4AfQAAAAAAYgAAAAGbG9jYQANAAAAAAHEAAAABm1heHAABAAGAAABCAAAACBuYW1lGZ8ZNAAAAegAAABycG9zdJ5/ds8AAAJcAAAALQABAAAAAQAAN7DqNF8PPPUAAwPoAAAAAOcaX1kAAAAA5xpfWQAA/zgB9AMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAH0AAAAAAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAACAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwH0AZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAIAMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAAAAAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAAAIP//AAAAIP///+EAAQAAAAAAAAAAAA0AAAABAAD/OAH0AyAAAwAAFREhEQH0yAPo/BgAAAAAAAAEADYAAQAAAAAAAQANAAAAAQAAAAAAAgAHAA0AAwABBAkAAQAaABQAAwABBAkAAgAOAC5HbHlwaExlc3NGb250UmVndWxhcgBHAGwAeQBwAGgATABlAHMAcwBGAG8AbgB0AFIAZQBnAHUAbABhAHIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAECBmdseXBoMQAAAA==';
|
||||
|
||||
export default glyphLessFontBase64;
|
||||
@@ -632,6 +632,38 @@ describe('ItemModel', () => {
|
||||
expect((await models().userItem().byUserId(user1.id)).length).toBe(5);
|
||||
});
|
||||
|
||||
test('should process orphaned items mixed with valid items across batches', async () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
const { user: user2 } = await createUserAndSession(2);
|
||||
|
||||
await createItemTree3(user1.id, '', '', [
|
||||
{ id: '000000000000000000000000000000F1' },
|
||||
]);
|
||||
|
||||
await createItemTree3(user2.id, '', '', [
|
||||
{ id: '000000000000000000000000000000F2' },
|
||||
{ id: '000000000000000000000000000000F3' },
|
||||
{ id: '000000000000000000000000000000F4' },
|
||||
{ id: '000000000000000000000000000000F5' },
|
||||
]);
|
||||
|
||||
const f4 = await models().item().loadByJopId(user2.id, '000000000000000000000000000000F4');
|
||||
await db()('user_items').where('item_id', '=', f4.id).delete();
|
||||
await db()('users').where('id', '=', user1.id).delete();
|
||||
await db()('user_items').where('user_id', '=', user1.id).delete();
|
||||
|
||||
await models().item().processOrphanedItems({ batchSize: 2 });
|
||||
|
||||
const items = await models().item().all();
|
||||
expect(items.map(i => i.name).sort()).toEqual([
|
||||
'000000000000000000000000000000F2.md',
|
||||
'000000000000000000000000000000F3.md',
|
||||
'000000000000000000000000000000F4.md',
|
||||
'000000000000000000000000000000F5.md',
|
||||
]);
|
||||
expect((await models().userItem().byUserId(user2.id)).length).toBe(4);
|
||||
});
|
||||
|
||||
test('should return multiple item contents', async () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
|
||||
|
||||
@@ -1102,6 +1102,10 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
// then look up which of those ids exist in user_items. The orphan
|
||||
// filter happens in TS. Per-query cost is bounded by the window size,
|
||||
// not by the table size, so no statement can blow past the timeout.
|
||||
//
|
||||
// The window query only selects `id` so that it is an index-only scan.
|
||||
// IDs are random, so any other column costs one heap page read per row,
|
||||
// and walking the whole heap evicts the buffer cache used by the app.
|
||||
const batchSize = options.batchSize ?? 10000;
|
||||
let batchNum = 0;
|
||||
let totalProcessed = 0;
|
||||
@@ -1114,7 +1118,7 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
const batchStartTime = Date.now();
|
||||
|
||||
const windowItems: Item[] = await this.db(this.tableName)
|
||||
.select(['items.id', 'items.name', 'items.owner_id'])
|
||||
.select(['items.id'])
|
||||
.where('items.id', '>', lastId)
|
||||
.orderBy('items.id')
|
||||
.limit(batchSize);
|
||||
@@ -1133,14 +1137,18 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
.select('item_id')
|
||||
.whereIn('item_id', windowItemIds);
|
||||
const userItemIds = new Set(existingUserItems.map(u => u.item_id));
|
||||
const orphanedItems = windowItems.filter(i => !userItemIds.has(i.id));
|
||||
const orphanedItemIds = windowItemIds.filter(id => !userItemIds.has(id));
|
||||
|
||||
if (!orphanedItems.length) {
|
||||
if (!orphanedItemIds.length) {
|
||||
const batchDuration = Date.now() - batchStartTime;
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - No orphans in window of ${windowItems.length} items (${batchDuration}ms)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orphanedItems: Item[] = await this.db(this.tableName)
|
||||
.select(['id', 'name', 'owner_id'])
|
||||
.whereIn('id', orphanedItemIds);
|
||||
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Found ${orphanedItems.length} orphaned items`);
|
||||
for (const o of orphanedItems) {
|
||||
modelLogger.info(`OrphanTrace: found orphan item=${o.id} owner=${o.owner_id} name=${o.name}`);
|
||||
@@ -1172,7 +1180,7 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
const restoreCount = Array.from(itemsToRestoreByUser.values()).reduce((sum, items) => sum + items.length, 0);
|
||||
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Restoring ${restoreCount} items for ${itemsToRestoreByUser.size} users`);
|
||||
for (const [userId, items] of itemsToRestoreByUser) {
|
||||
await this.models().userItem().addMulti(userId, items);
|
||||
await this.models().userItem().addMulti(userId, items, { ignoreAlreadyExists: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ describe('shares', () => {
|
||||
const result = await getApi<Share>('', `shares/${share.id}`);
|
||||
expect(result.id).toBe(share.id);
|
||||
expect(result.type).toBe(ShareType.PublishedFolder);
|
||||
expect(result.owner_id).toBeUndefined();
|
||||
|
||||
const ownerResult = await getApi<Share>(session.id, `shares/${share.id}`);
|
||||
expect(ownerResult.owner_id).toBe(share.owner_id);
|
||||
});
|
||||
|
||||
test('should not reveal whether an email has an account', async () => {
|
||||
|
||||
@@ -132,7 +132,10 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
if (share && (share.type === ShareType.Note || share.type === ShareType.PublishedFolder)) {
|
||||
// No authentication is necessary - anyone who knows the share ID is allowed
|
||||
// to access the file. It is essentially public.
|
||||
return shareModel.toApiOutput(share);
|
||||
const output = await shareModel.toApiOutput(share) as Share;
|
||||
// Published links are indexed by search engines, and the owner's ID would let anyone target their account
|
||||
if (ctx.joplin.owner?.id !== share.owner_id) delete output.owner_id;
|
||||
return output;
|
||||
}
|
||||
|
||||
throw new ErrorNotFound();
|
||||
|
||||
@@ -222,6 +222,9 @@ describe('index/users', () => {
|
||||
// "confirm_email" is set to 0.
|
||||
user1 = await models().user().load(user1.id);
|
||||
expect(user1.email_confirmed).toBe(0);
|
||||
|
||||
// The token is still required
|
||||
await expectHttpError(() => execRequest('', 'GET', path, null, { query: { token: 'invalid', confirm_email: '0' } }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
test('should allow user to verify their email', async () => {
|
||||
|
||||
@@ -166,7 +166,12 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.query.confirm_email !== '0') await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
|
||||
if (ctx.query.confirm_email !== '0') {
|
||||
await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
|
||||
} else {
|
||||
// Otherwise this page would reveal whether an account exists, and the email of an account without a password
|
||||
await models.token().checkToken(userId, token, TokenPurpose.AccountConfirmation);
|
||||
}
|
||||
|
||||
const user = await models.user().load(userId);
|
||||
if (!user) throw new ErrorNotFound(`No such user: ${userId}`);
|
||||
|
||||
@@ -63,10 +63,12 @@ export default async function(env: Env, models: Models, config: Config, services
|
||||
run: (models: Models) => models.user().handleOversizedAccounts(),
|
||||
},
|
||||
|
||||
// Walks the whole items table, so it runs once a day rather than
|
||||
// hourly. Orphans are rare and clients work around them meanwhile.
|
||||
{
|
||||
id: TaskId.ProcessOrphanedItems,
|
||||
description: taskIdToLabel(TaskId.ProcessOrphanedItems),
|
||||
schedule: '15 * * * *',
|
||||
schedule: '15 4 * * *',
|
||||
run: (models: Models) => models.item().processOrphanedItems(),
|
||||
},
|
||||
|
||||
|
||||
@@ -378,3 +378,7 @@ instr
|
||||
mixmark
|
||||
Hrvatski
|
||||
Defense
|
||||
glyphless
|
||||
bfrange
|
||||
flate
|
||||
Thaana
|
||||
Reference in new issue
Block a user