Revert "Merge branch 'master' of https://github.com/cubfur/philomena"

This reverts commit 288bafbd65, reversing
changes made to 4e60f61e89.
This commit is contained in:
Chaska 2024-05-01 00:32:44 -05:00
parent 288bafbd65
commit ec8af94387
49 changed files with 6902 additions and 2559 deletions

2
.gitignore vendored
View file

@ -59,6 +59,6 @@ npm-debug.log
/native/**/target /native/**/target
/.cargo /.cargo
# Vitest coverage # Jest coverage
/assets/coverage /assets/coverage
docker/app/Dockerfile docker/app/Dockerfile

View file

@ -1,2 +1,3 @@
js/vendor/* js/vendor/*
vite.config.ts webpack.config.js
jest.config.js

View file

@ -10,7 +10,7 @@ parserOptions:
plugins: plugins:
- '@typescript-eslint' - '@typescript-eslint'
- vitest - jest
globals: globals:
ga: false ga: false
@ -276,14 +276,12 @@ overrides:
'@typescript-eslint/no-extra-parens': 2 '@typescript-eslint/no-extra-parens': 2
no-shadow: 0 no-shadow: 0
'@typescript-eslint/no-shadow': 2 '@typescript-eslint/no-shadow': 2
# Unit Tests (also written in TypeScript) # Jest Tests (also written in TypeScript)
# Disable rules that do not make sense in test files (e.g. testing for undefined input values should be allowed) # Disable rules that do not make sense in test files (e.g. testing for undefined input values should be allowed)
- files: - files:
- '*.spec.ts' - '*.spec.ts'
- 'test/*.ts' - 'test/*.ts'
extends: extends:
- 'plugin:vitest/legacy-recommended' - 'plugin:jest/recommended'
rules: rules:
no-undefined: 0 no-undefined: 0
no-unused-expressions: 0
vitest/valid-expect: 0

View file

@ -9,13 +9,13 @@
@import "global"; @import "global";
// Because FA is a SPECIAL SNOWFLAKE. // Because FA is a SPECIAL SNOWFLAKE.
$fa-font-path: '@fortawesome/fontawesome-free/webfonts'; $fa-font-path: '~@fortawesome/fontawesome-free/webfonts';
@import "@fortawesome/fontawesome-free/scss/fontawesome.scss"; @import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "@fortawesome/fontawesome-free/scss/solid.scss"; @import "~@fortawesome/fontawesome-free/scss/solid.scss";
@import "@fortawesome/fontawesome-free/scss/regular.scss"; @import "~@fortawesome/fontawesome-free/scss/regular.scss";
@import "@fortawesome/fontawesome-free/scss/brands.scss"; @import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "normalize-scss/sass/normalize/import-now"; @import "~normalize-scss/sass/normalize/import-now";
body { body {
background-color: $background_color; background-color: $background_color;
@ -469,26 +469,26 @@ span.stat {
@import "shame"; @import "shame";
@import "text"; @import "text";
@import "views/adverts"; @import "~views/adverts";
@import "views/approval"; @import "~views/approval";
@import "views/badges"; @import "~views/badges";
@import "views/channels"; @import "~views/channels";
@import "views/comments"; @import "~views/comments";
@import "views/commissions"; @import "~views/commissions";
@import "views/communications"; @import "~views/communications";
@import "views/duplicate_reports"; @import "~views/duplicate_reports";
@import "views/filters"; @import "~views/filters";
@import "views/galleries"; @import "~views/galleries";
@import "views/images"; @import "~views/images";
@import "views/pages"; @import "~views/pages";
@import "views/polls"; @import "~views/polls";
@import "views/posts"; @import "~views/posts";
@import "views/profiles"; @import "~views/profiles";
@import "views/pagination"; @import "~views/pagination";
@import "views/search"; @import "~views/search";
@import "views/staff"; @import "~views/staff";
@import "views/stats"; @import "~views/stats";
@import "views/tags"; @import "~views/tags";
.no-overflow { .no-overflow {
overflow: hidden; overflow: hidden;

View file

@ -124,8 +124,6 @@ a.block__header--single-item, .block__header a {
.block__header--js-tabbed { .block__header--js-tabbed {
@extend .block__header--light; @extend .block__header--light;
background: transparent; background: transparent;
display: flex;
flex-wrap: wrap;
border-bottom: $border; border-bottom: $border;
a { a {

View file

@ -23,11 +23,6 @@
padding-left: 6px; padding-left: 6px;
} }
.header__navigation {
display: flex;
flex-wrap: wrap;
}
a.header__link { a.header__link {
display: inline-block; display: inline-block;
padding: 0 $header_spacing; padding: 0 $header_spacing;

View file

@ -190,4 +190,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%);
$poll_form_label_background: lighten($border_color, 8); $poll_form_label_background: lighten($border_color, 8);
$tag_dropdown_hover_background: darken($meta_color, 4%); $tag_dropdown_hover_background: darken($meta_color, 4%);
@import "common/base"; @import "~common/base";

View file

@ -180,4 +180,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%);
$poll_form_label_background: lighten($border_color, 8); $poll_form_label_background: lighten($border_color, 8);
$tag_dropdown_hover_background: darken($meta_color, 4%); $tag_dropdown_hover_background: darken($meta_color, 4%);
@import "common/base"; @import "~common/base";

View file

@ -192,4 +192,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%);
$poll_form_label_background: lighten($border_color, 8); $poll_form_label_background: lighten($border_color, 8);
$tag_dropdown_hover_background: darken($meta_color, 4%); $tag_dropdown_hover_background: darken($meta_color, 4%);
@import "common/base"; @import "~common/base";

View file

@ -92,6 +92,12 @@ div.image-container {
overflow: hidden; overflow: hidden;
/* prevent .media-box__overlay from overflowing the container */ /* prevent .media-box__overlay from overflowing the container */
text-align: center; text-align: center;
a::before {
content: "";
display: inline-block;
height: 100%;
vertical-align: middle;
}
img, img,
video { video {
vertical-align: middle; vertical-align: middle;
@ -99,12 +105,12 @@ div.image-container {
max-height: 100%; max-height: 100%;
} }
/* Make the link cover the whole container if the image is oblong */ /* Make the link cover the whole container if the image is oblong */
a, picture, video { a {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: inline-flex; display: inline-block;
align-items: center; text-align: center;
justify-content: center; vertical-align: middle;
} }
} }

View file

@ -70,11 +70,7 @@
.tag > span { .tag > span {
padding: 5px; padding: 5px;
display: table-cell; display: table-cell;
} white-space: pre;
.tag-list {
display: flex;
flex-wrap: wrap;
} }
.tag a { .tag a {

13
assets/fix-jsdom.ts Normal file
View file

@ -0,0 +1,13 @@
import JSDOMEnvironment from 'jest-environment-jsdom';
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
super(...args);
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
// jsdom URL and Blob are missing most of the implementation
// Use the node version of these types instead
this.global.URL = URL;
this.global.Blob = Blob;
}
}

42
assets/jest.config.js Normal file
View file

@ -0,0 +1,42 @@
export default {
collectCoverage: true,
collectCoverageFrom: [
'js/**/*.{js,ts}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/.*\\.test\\.ts$',
'.*\\.d\\.ts$',
],
coverageDirectory: '<rootDir>/coverage/',
coverageThreshold: {
global: {
statements: 0,
branches: 0,
functions: 0,
lines: 0,
},
'./js/utils/**/*.ts': {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
preset: 'ts-jest/presets/js-with-ts-esm',
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
testEnvironment: './fix-jsdom.ts',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
moduleNameMapper: {
'./js/(.*)': '<rootDir>/js/$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: '<rootDir>/tsconfig.json',
useESM: true,
}]
},
globals: {
extensionsToTreatAsEsm: ['.ts', '.js'],
}
};

View file

@ -1,7 +1,6 @@
import { inputDuplicatorCreator } from '../input-duplicator'; import { inputDuplicatorCreator } from '../input-duplicator';
import { assertNotNull } from '../utils/assert'; import { assertNotNull } from '../utils/assert';
import { $, $$, removeEl } from '../utils/dom'; import { $, $$, removeEl } from '../utils/dom';
import { fireEvent } from '@testing-library/dom';
describe('Input duplicator functionality', () => { describe('Input duplicator functionality', () => {
beforeEach(() => { beforeEach(() => {
@ -42,7 +41,7 @@ describe('Input duplicator functionality', () => {
expect($$('input')).toHaveLength(1); expect($$('input')).toHaveLength(1);
fireEvent.click(assertNotNull($<HTMLButtonElement>('.js-add-input'))); assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
expect($$('input')).toHaveLength(2); expect($$('input')).toHaveLength(2);
}); });
@ -54,7 +53,7 @@ describe('Input duplicator functionality', () => {
form.insertAdjacentElement('afterbegin', buttonDiv); form.insertAdjacentElement('afterbegin', buttonDiv);
runCreator(); runCreator();
fireEvent.click(assertNotNull($<HTMLButtonElement>('.js-add-input'))); assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
expect($$('input')).toHaveLength(2); expect($$('input')).toHaveLength(2);
}); });
@ -63,7 +62,7 @@ describe('Input duplicator functionality', () => {
runCreator(); runCreator();
for (let i = 0; i < 5; i += 1) { for (let i = 0; i < 5; i += 1) {
fireEvent.click(assertNotNull($<HTMLButtonElement>('.js-add-input'))); assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
} }
expect($$('input')).toHaveLength(3); expect($$('input')).toHaveLength(3);
@ -72,8 +71,8 @@ describe('Input duplicator functionality', () => {
it('should remove duplicated input elements', () => { it('should remove duplicated input elements', () => {
runCreator(); runCreator();
fireEvent.click(assertNotNull($<HTMLButtonElement>('.js-add-input'))); assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
fireEvent.click(assertNotNull($<HTMLAnchorElement>('.js-remove-input'))); assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
expect($$('input')).toHaveLength(1); expect($$('input')).toHaveLength(1);
}); });
@ -81,10 +80,10 @@ describe('Input duplicator functionality', () => {
it('should not remove the last input element', () => { it('should not remove the last input element', () => {
runCreator(); runCreator();
fireEvent.click(assertNotNull($<HTMLAnchorElement>('.js-remove-input'))); assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
fireEvent.click(assertNotNull($<HTMLAnchorElement>('.js-remove-input'))); assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
for (let i = 0; i < 5; i += 1) { for (let i = 0; i < 5; i += 1) {
fireEvent.click(assertNotNull($<HTMLAnchorElement>('.js-remove-input'))); assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
} }
expect($$('input')).toHaveLength(1); expect($$('input')).toHaveLength(1);

View file

@ -1,7 +1,7 @@
import fetchMock from 'jest-fetch-mock';
import { fireEvent, waitFor } from '@testing-library/dom'; import { fireEvent, waitFor } from '@testing-library/dom';
import { assertType } from '../utils/assert'; import { assertType } from '../utils/assert';
import '../ujs'; import '../ujs';
import { fetchMock } from '../../test/fetch-mock';
const mockEndpoint = 'http://localhost/endpoint'; const mockEndpoint = 'http://localhost/endpoint';
const mockVerb = 'POST'; const mockVerb = 'POST';
@ -38,7 +38,7 @@ describe('Remote utilities', () => {
} }
document.documentElement.insertAdjacentElement('beforeend', a); document.documentElement.insertAdjacentElement('beforeend', a);
fireEvent.click(a, { button: 0 }); a.click();
return a; return a;
}; };
@ -88,7 +88,7 @@ describe('Remote utilities', () => {
a.dataset.method = mockVerb; a.dataset.method = mockVerb;
document.documentElement.insertAdjacentElement('beforeend', a); document.documentElement.insertAdjacentElement('beforeend', a);
fireEvent.click(a); a.click();
return a; return a;
}; };
@ -128,7 +128,7 @@ describe('Remote utilities', () => {
...Object.getOwnPropertyDescriptors(oldWindowLocation), ...Object.getOwnPropertyDescriptors(oldWindowLocation),
reload: { reload: {
configurable: true, configurable: true,
value: vi.fn(), value: jest.fn(),
}, },
}, },
); );
@ -155,7 +155,7 @@ describe('Remote utilities', () => {
const submitForm = () => { const submitForm = () => {
const form = configureForm(); const form = configureForm();
form.method = mockVerb; form.method = mockVerb;
fireEvent.submit(form); form.submit();
return form; return form;
}; };
@ -176,7 +176,7 @@ describe('Remote utilities', () => {
it('should submit a PUT request with put data-method specified', () => { it('should submit a PUT request with put data-method specified', () => {
const form = configureForm(); const form = configureForm();
form.dataset.method = 'put'; form.dataset.method = 'put';
fireEvent.submit(form); form.submit();
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
method: 'PUT', method: 'PUT',
@ -201,7 +201,7 @@ describe('Remote utilities', () => {
})); }));
it('should reload the page on 300 multiple choices response', () => { it('should reload the page on 300 multiple choices response', () => {
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300})); jest.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300}));
submitForm(); submitForm();
return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1)); return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1));
@ -211,29 +211,28 @@ describe('Remote utilities', () => {
describe('Form utilities', () => { describe('Form utilities', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
cb(1); cb(1);
return 1; return 1;
}); });
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); jest.clearAllMocks();
}); });
describe('[data-confirm]', () => { describe('[data-confirm]', () => {
const createA = () => { const createA = () => {
const a = document.createElement('a'); const a = document.createElement('a');
a.dataset.confirm = 'confirm'; a.dataset.confirm = 'confirm';
// We cannot use mockEndpoint here since anything except a hash change will log an error in the test output a.href = mockEndpoint;
a.href = '#hash';
document.documentElement.insertAdjacentElement('beforeend', a); document.documentElement.insertAdjacentElement('beforeend', a);
return a; return a;
}; };
it('should cancel the event on failed confirm', () => { it('should cancel the event on failed confirm', () => {
const a = createA(); const a = createA();
const confirm = vi.spyOn(window, 'confirm').mockImplementationOnce(() => false); const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => false);
const event = new MouseEvent('click', { bubbles: true, cancelable: true }); const event = new MouseEvent('click', { bubbles: true, cancelable: true });
expect(fireEvent(a, event)).toBe(false); expect(fireEvent(a, event)).toBe(false);
@ -242,7 +241,7 @@ describe('Form utilities', () => {
it('should allow the event on confirm', () => { it('should allow the event on confirm', () => {
const a = createA(); const a = createA();
const confirm = vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => true);
const event = new MouseEvent('click', { bubbles: true, cancelable: true }); const event = new MouseEvent('click', { bubbles: true, cancelable: true });
expect(fireEvent(a, event)).toBe(true); expect(fireEvent(a, event)).toBe(true);
@ -277,7 +276,7 @@ describe('Form utilities', () => {
it('should disable submit button containing a text child on click', () => { it('should disable submit button containing a text child on click', () => {
const [ , button ] = createFormAndButton(submitText, loadingText); const [ , button ] = createFormAndButton(submitText, loadingText);
fireEvent.click(button); button.click();
expect(button.textContent).toEqual(' Loading...'); expect(button.textContent).toEqual(' Loading...');
expect(button.dataset.enableWith).toEqual(submitText); expect(button.dataset.enableWith).toEqual(submitText);
@ -285,7 +284,7 @@ describe('Form utilities', () => {
it('should disable submit button containing element children on click', () => { it('should disable submit button containing element children on click', () => {
const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup); const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup);
fireEvent.click(button); button.click();
expect(button.innerHTML).toEqual(loadingMarkup); expect(button.innerHTML).toEqual(loadingMarkup);
expect(button.dataset.enableWith).toEqual(submitMarkup); expect(button.dataset.enableWith).toEqual(submitMarkup);
@ -294,7 +293,7 @@ describe('Form utilities', () => {
it('should not disable anything when the form is invalid', () => { it('should not disable anything when the form is invalid', () => {
const [ form, button ] = createFormAndButton(submitText, loadingText); const [ form, button ] = createFormAndButton(submitText, loadingText);
form.insertAdjacentHTML('afterbegin', '<input type="text" name="valid" required="true" />'); form.insertAdjacentHTML('afterbegin', '<input type="text" name="valid" required="true" />');
fireEvent.click(button); button.click();
expect(button.textContent).toEqual(submitText); expect(button.textContent).toEqual(submitText);
expect(button.dataset.enableWith).not.toBeDefined(); expect(button.dataset.enableWith).not.toBeDefined();
@ -302,7 +301,7 @@ describe('Form utilities', () => {
it('should reset submit button containing a text child on completion', () => { it('should reset submit button containing a text child on completion', () => {
const [ form, button ] = createFormAndButton(submitText, loadingText); const [ form, button ] = createFormAndButton(submitText, loadingText);
fireEvent.click(button); button.click();
fireEvent(form, new CustomEvent('reset', { bubbles: true })); fireEvent(form, new CustomEvent('reset', { bubbles: true }));
expect(button.textContent?.trim()).toEqual(submitText); expect(button.textContent?.trim()).toEqual(submitText);
@ -311,7 +310,7 @@ describe('Form utilities', () => {
it('should reset submit button containing element children on completion', () => { it('should reset submit button containing element children on completion', () => {
const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup); const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup);
fireEvent.click(button); button.click();
fireEvent(form, new CustomEvent('reset', { bubbles: true })); fireEvent(form, new CustomEvent('reset', { bubbles: true }));
expect(button.innerHTML).toEqual(submitMarkup); expect(button.innerHTML).toEqual(submitMarkup);
@ -320,7 +319,7 @@ describe('Form utilities', () => {
it('should reset disabled form elements on pageshow', () => { it('should reset disabled form elements on pageshow', () => {
const [ , button ] = createFormAndButton(submitText, loadingText); const [ , button ] = createFormAndButton(submitText, loadingText);
fireEvent.click(button); button.click();
fireEvent(window, new CustomEvent('pageshow')); fireEvent(window, new CustomEvent('pageshow'));
expect(button.textContent?.trim()).toEqual(submitText); expect(button.textContent?.trim()).toEqual(submitText);

View file

@ -1,7 +1,7 @@
import { $, $$, removeEl } from '../utils/dom'; import { $, $$, removeEl } from '../utils/dom';
import { assertNotNull, assertNotUndefined } from '../utils/assert'; import { assertNotNull, assertNotUndefined } from '../utils/assert';
import { fetchMock } from '../../test/fetch-mock'; import fetchMock from 'jest-fetch-mock';
import { fixEventListeners } from '../../test/fix-event-listeners'; import { fixEventListeners } from '../../test/fix-event-listeners';
import { fireEvent, waitFor } from '@testing-library/dom'; import { fireEvent, waitFor } from '@testing-library/dom';
import { promises } from 'fs'; import { promises } from 'fs';
@ -13,8 +13,8 @@ import { setupImageUpload } from '../upload';
const scrapeResponse = { const scrapeResponse = {
description: 'test', description: 'test',
images: [ images: [
{ url: 'http://localhost/images/1', camo_url: 'http://localhost/images/1' }, {url: 'http://localhost/images/1', camo_url: 'http://localhost/images/1'},
{ url: 'http://localhost/images/2', camo_url: 'http://localhost/images/2' }, {url: 'http://localhost/images/2', camo_url: 'http://localhost/images/2'},
], ],
source_url: 'http://localhost/images', source_url: 'http://localhost/images',
author_name: 'test', author_name: 'test',
@ -47,7 +47,6 @@ describe('Image upload form', () => {
fixEventListeners(window); fixEventListeners(window);
let form: HTMLFormElement; let form: HTMLFormElement;
let imgPreviews: HTMLDivElement; let imgPreviews: HTMLDivElement;
let fileField: HTMLInputElement; let fileField: HTMLInputElement;
@ -58,10 +57,6 @@ describe('Image upload form', () => {
let sourceEl: HTMLInputElement; let sourceEl: HTMLInputElement;
let descrEl: HTMLTextAreaElement; let descrEl: HTMLTextAreaElement;
const assertFetchButtonIsDisabled = () => {
if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled');
};
beforeEach(() => { beforeEach(() => {
document.documentElement.insertAdjacentHTML('beforeend', ` document.documentElement.insertAdjacentHTML('beforeend', `
<form action="/images"> <form action="/images">
@ -96,37 +91,28 @@ describe('Image upload form', () => {
}); });
it('should disable fetch button on empty source', () => { it('should disable fetch button on empty source', () => {
fireEvent.input(remoteUrl, { target: { value: '' } }); fireEvent.input(remoteUrl, { target: { value: '' }});
expect(fetchButton.disabled).toBe(true); expect(fetchButton.disabled).toBe(true);
}); });
it('should enable fetch button on non-empty source', () => { it('should enable fetch button on non-empty source', () => {
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
expect(fetchButton.disabled).toBe(false); expect(fetchButton.disabled).toBe(false);
}); });
it('should create a preview element when an image file is uploaded', () => { it('should create a preview element when an image file is uploaded', () => {
fireEvent.change(fileField, { target: { files: [mockPng] } }); fireEvent.change(fileField, { target: { files: [mockPng] }});
return waitFor(() => { return waitFor(() => expect(imgPreviews.querySelectorAll('img')).toHaveLength(1));
assertFetchButtonIsDisabled();
expect(imgPreviews.querySelectorAll('img')).toHaveLength(1);
});
}); });
it('should create a preview element when a Matroska video file is uploaded', () => { it('should create a preview element when a Matroska video file is uploaded', () => {
fireEvent.change(fileField, { target: { files: [mockWebm] } }); fireEvent.change(fileField, { target: { files: [mockWebm] }});
return waitFor(() => { return waitFor(() => expect(imgPreviews.querySelectorAll('video')).toHaveLength(1));
assertFetchButtonIsDisabled();
expect(imgPreviews.querySelectorAll('video')).toHaveLength(1);
});
}); });
it('should block navigation away after an image file is attached, but not after form submission', async() => { it('should block navigation away after an image file is attached, but not after form submission', async() => {
fireEvent.change(fileField, { target: { files: [mockPng] } }); fireEvent.change(fileField, { target: { files: [mockPng] }});
await waitFor(() => { await waitFor(() => { expect(imgPreviews.querySelectorAll('img')).toHaveLength(1); });
assertFetchButtonIsDisabled();
expect(imgPreviews.querySelectorAll('img')).toHaveLength(1);
});
const failedUnloadEvent = new Event('beforeunload', { cancelable: true }); const failedUnloadEvent = new Event('beforeunload', { cancelable: true });
expect(fireEvent(window, failedUnloadEvent)).toBe(false); expect(fireEvent(window, failedUnloadEvent)).toBe(false);
@ -136,7 +122,7 @@ describe('Image upload form', () => {
event.preventDefault(); event.preventDefault();
resolve(); resolve();
}); });
fireEvent.submit(form); form.submit();
}); });
const succeededUnloadEvent = new Event('beforeunload', { cancelable: true }); const succeededUnloadEvent = new Event('beforeunload', { cancelable: true });
@ -145,11 +131,11 @@ describe('Image upload form', () => {
it('should scrape images when the fetch button is clicked', async() => { it('should scrape images when the fetch button is clicked', async() => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 })); fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 }));
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
tagsEl.addEventListener('addtag', (event: Event) => { tagsEl.addEventListener('addtag', (event: Event) => {
expect((event as CustomEvent).detail).toEqual({ name: 'artist:test' }); expect((event as CustomEvent).detail).toEqual({name: 'artist:test'});
resolve(); resolve();
}); });
@ -167,8 +153,8 @@ describe('Image upload form', () => {
it('should show null scrape result', () => { it('should show null scrape result', () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(nullResponse), { status: 200 })); fetchMock.mockResolvedValue(new Response(JSON.stringify(nullResponse), { status: 200 }));
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
fireEvent.click(fetchButton); fetchButton.click();
return waitFor(() => { return waitFor(() => {
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
@ -180,8 +166,8 @@ describe('Image upload form', () => {
it('should show error scrape result', () => { it('should show error scrape result', () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(errorResponse), { status: 200 })); fetchMock.mockResolvedValue(new Response(JSON.stringify(errorResponse), { status: 200 }));
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
fireEvent.click(fetchButton); fetchButton.click();
return waitFor(() => { return waitFor(() => {
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);

View file

@ -8,12 +8,3 @@
// Our code // Our code
import './ujs'; import './ujs';
import './when-ready'; import './when-ready';
// When developing CSS, include the relevant CSS you're working on here
// in order to enable HMR (live reload) on it.
// Would typically be either the theme file, or any additional file
// you later intend to put in the <link> tag.
// import '../css/themes/default.scss';
// import '../css/themes/dark.scss';
// import '../css/themes/red.scss';

View file

@ -2,8 +2,8 @@
* Autocomplete. * Autocomplete.
*/ */
import { LocalAutocompleter } from './utils/local-autocompleter'; import { LocalAutocompleter } from 'utils/local-autocompleter';
import { handleError } from './utils/requests'; import { handleError } from 'utils/requests';
const cache = {}; const cache = {};
let inputField, originalTerm; let inputField, originalTerm;

View file

@ -29,11 +29,11 @@ describe('DOM Utilities', () => {
describe('$', () => { describe('$', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('should call the native querySelector method on document by default', () => { it('should call the native querySelector method on document by default', () => {
const spy = vi.spyOn(document, 'querySelector'); const spy = jest.spyOn(document, 'querySelector');
mockSelectors.forEach((selector, nthCall) => { mockSelectors.forEach((selector, nthCall) => {
$(selector); $(selector);
@ -43,7 +43,7 @@ describe('DOM Utilities', () => {
it('should call the native querySelector method on the passed element', () => { it('should call the native querySelector method on the passed element', () => {
const mockElement = document.createElement('br'); const mockElement = document.createElement('br');
const spy = vi.spyOn(mockElement, 'querySelector'); const spy = jest.spyOn(mockElement, 'querySelector');
mockSelectors.forEach((selector, nthCall) => { mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed // FIXME This will not be necessary once the file is properly typed
@ -55,11 +55,11 @@ describe('DOM Utilities', () => {
describe('$$', () => { describe('$$', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('should call the native querySelectorAll method on document by default', () => { it('should call the native querySelectorAll method on document by default', () => {
const spy = vi.spyOn(document, 'querySelectorAll'); const spy = jest.spyOn(document, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => { mockSelectors.forEach((selector, nthCall) => {
$$(selector); $$(selector);
@ -69,7 +69,7 @@ describe('DOM Utilities', () => {
it('should call the native querySelectorAll method on the passed element', () => { it('should call the native querySelectorAll method on the passed element', () => {
const mockElement = document.createElement('br'); const mockElement = document.createElement('br');
const spy = vi.spyOn(mockElement, 'querySelectorAll'); const spy = jest.spyOn(mockElement, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => { mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed // FIXME This will not be necessary once the file is properly typed
@ -311,7 +311,7 @@ describe('DOM Utilities', () => {
describe('removeEl', () => { describe('removeEl', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('should NOT throw error if element has no parent', () => { it('should NOT throw error if element has no parent', () => {
@ -324,7 +324,7 @@ describe('DOM Utilities', () => {
const childNode = document.createElement('p'); const childNode = document.createElement('p');
parentNode.appendChild(childNode); parentNode.appendChild(childNode);
const spy = vi.spyOn(parentNode, 'removeChild'); const spy = jest.spyOn(parentNode, 'removeChild');
removeEl(childNode); removeEl(childNode);
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
@ -374,7 +374,7 @@ describe('DOM Utilities', () => {
}); });
it('should call callback on left click', () => { it('should call callback on left click', () => {
const mockCallback = vi.fn(); const mockCallback = jest.fn();
const element = document.createElement('div'); const element = document.createElement('div');
cleanup = onLeftClick(mockCallback, element as unknown as Document); cleanup = onLeftClick(mockCallback, element as unknown as Document);
@ -384,7 +384,7 @@ describe('DOM Utilities', () => {
}); });
it('should NOT call callback on non-left click', () => { it('should NOT call callback on non-left click', () => {
const mockCallback = vi.fn(); const mockCallback = jest.fn();
const element = document.createElement('div'); const element = document.createElement('div');
cleanup = onLeftClick(mockCallback, element as unknown as Document); cleanup = onLeftClick(mockCallback, element as unknown as Document);
@ -395,7 +395,7 @@ describe('DOM Utilities', () => {
}); });
it('should add click event listener to the document by default', () => { it('should add click event listener to the document by default', () => {
const mockCallback = vi.fn(); const mockCallback = jest.fn();
cleanup = onLeftClick(mockCallback); cleanup = onLeftClick(mockCallback);
fireEvent.click(document.body); fireEvent.click(document.body);
@ -404,7 +404,7 @@ describe('DOM Utilities', () => {
}); });
it('should return a cleanup function that removes the listener', () => { it('should return a cleanup function that removes the listener', () => {
const mockCallback = vi.fn(); const mockCallback = jest.fn();
const element = document.createElement('div'); const element = document.createElement('div');
const localCleanup = onLeftClick(mockCallback, element as unknown as Document); const localCleanup = onLeftClick(mockCallback, element as unknown as Document);
@ -424,8 +424,8 @@ describe('DOM Utilities', () => {
describe('whenReady', () => { describe('whenReady', () => {
it('should call callback immediately if document ready state is not loading', () => { it('should call callback immediately if document ready state is not loading', () => {
const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']); const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']);
const readyStateSpy = vi.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue); const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue);
const mockCallback = vi.fn(); const mockCallback = jest.fn();
try { try {
whenReady(mockCallback); whenReady(mockCallback);
@ -437,9 +437,9 @@ describe('DOM Utilities', () => {
}); });
it('should add event listener with callback if document ready state is loading', () => { it('should add event listener with callback if document ready state is loading', () => {
const readyStateSpy = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
const mockCallback = vi.fn(); const mockCallback = jest.fn();
try { try {
whenReady(mockCallback); whenReady(mockCallback);

View file

@ -30,7 +30,7 @@ describe('Draggable Utilities', () => {
const draggingClass = 'dragging'; const draggingClass = 'dragging';
const dragContainerClass = 'drag-container'; const dragContainerClass = 'drag-container';
const dragOverClass = 'over'; const dragOverClass = 'over';
let documentEventListenerSpy: MockInstance; let documentEventListenerSpy: jest.SpyInstance;
let mockDragContainer: HTMLDivElement; let mockDragContainer: HTMLDivElement;
let mockDraggable: HTMLDivElement; let mockDraggable: HTMLDivElement;
@ -45,7 +45,7 @@ describe('Draggable Utilities', () => {
// Redirect all document event listeners to this element for easier cleanup // Redirect all document event listeners to this element for easier cleanup
documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => { documentEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation((...params) => {
mockDragContainer.addEventListener(...params); mockDragContainer.addEventListener(...params);
}); });
}); });
@ -192,7 +192,7 @@ describe('Draggable Utilities', () => {
const mockDropEvent = createDragEvent('drop'); const mockDropEvent = createDragEvent('drop');
Object.assign(mockDropEvent, { clientX: 124 }); Object.assign(mockDropEvent, { clientX: 124 });
const boundingBoxSpy = vi.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({ const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
left: 100, left: 100,
width: 50, width: 50,
} as unknown as DOMRect); } as unknown as DOMRect);
@ -221,7 +221,7 @@ describe('Draggable Utilities', () => {
const mockDropEvent = createDragEvent('drop'); const mockDropEvent = createDragEvent('drop');
Object.assign(mockDropEvent, { clientX: 125 }); Object.assign(mockDropEvent, { clientX: 125 });
const boundingBoxSpy = vi.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({ const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
left: 100, left: 100,
width: 50, width: 50,
} as unknown as DOMRect); } as unknown as DOMRect);
@ -291,7 +291,7 @@ describe('Draggable Utilities', () => {
initDraggables(); initDraggables();
const mockEvent = createDragEvent('dragstart'); const mockEvent = createDragEvent('dragstart');
const draggableClosestSpy = vi.spyOn(mockDraggable, 'closest').mockReturnValue(null); const draggableClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
try { try {
fireEvent(mockDraggable, mockEvent); fireEvent(mockDraggable, mockEvent);

View file

@ -8,7 +8,7 @@ describe('Event utils', () => {
describe('fire', () => { describe('fire', () => {
it('should call the native dispatchEvent method on the element', () => { it('should call the native dispatchEvent method on the element', () => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
const dispatchEventSpy = vi.spyOn(mockElement, 'dispatchEvent'); const dispatchEventSpy = jest.spyOn(mockElement, 'dispatchEvent');
const mockDetail = getRandomArrayItem([0, 'test', null]); const mockDetail = getRandomArrayItem([0, 'test', null]);
fire(mockElement, mockEvent, mockDetail); fire(mockElement, mockEvent, mockDetail);
@ -42,7 +42,7 @@ describe('Event utils', () => {
mockButton.classList.add('mock-button'); mockButton.classList.add('mock-button');
mockInnerElement.appendChild(mockButton); mockInnerElement.appendChild(mockButton);
const mockHandler = vi.fn(); const mockHandler = jest.fn();
on(mockElement, 'click', `.${innerClass}`, mockHandler); on(mockElement, 'click', `.${innerClass}`, mockHandler);
fireEvent(mockButton, new Event('click', { bubbles: true })); fireEvent(mockButton, new Event('click', { bubbles: true }));
@ -58,7 +58,7 @@ describe('Event utils', () => {
describe('leftClick', () => { describe('leftClick', () => {
it('should fire on left click', () => { it('should fire on left click', () => {
const mockButton = document.createElement('button'); const mockButton = document.createElement('button');
const mockHandler = vi.fn(); const mockHandler = jest.fn();
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton)); mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
@ -69,7 +69,7 @@ describe('Event utils', () => {
it('should NOT fire on any other click', () => { it('should NOT fire on any other click', () => {
const mockButton = document.createElement('button'); const mockButton = document.createElement('button');
const mockHandler = vi.fn(); const mockHandler = jest.fn();
const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]); const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]);
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton)); mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
@ -83,7 +83,7 @@ describe('Event utils', () => {
describe('delegate', () => { describe('delegate', () => {
it('should call the native addEventListener method on the element', () => { it('should call the native addEventListener method on the element', () => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
const addEventListenerSpy = vi.spyOn(mockElement, 'addEventListener'); const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
delegate(mockElement, mockEvent, {}); delegate(mockElement, mockEvent, {});
@ -102,7 +102,7 @@ describe('Event utils', () => {
const mockButton = document.createElement('button'); const mockButton = document.createElement('button');
mockElement.appendChild(mockButton); mockElement.appendChild(mockButton);
const mockHandler = vi.fn(); const mockHandler = jest.fn();
delegate(mockElement, 'click', { [`.${parentClass}`]: mockHandler }); delegate(mockElement, 'click', { [`.${parentClass}`]: mockHandler });
fireEvent(mockButton, new Event('click', { bubbles: true })); fireEvent(mockButton, new Event('click', { bubbles: true }));
@ -127,8 +127,8 @@ describe('Event utils', () => {
const mockButton = document.createElement('button'); const mockButton = document.createElement('button');
mockWrapperElement.appendChild(mockButton); mockWrapperElement.appendChild(mockButton);
const mockParentHandler = vi.fn(); const mockParentHandler = jest.fn();
const mockWrapperHandler = vi.fn().mockReturnValue(false); const mockWrapperHandler = jest.fn().mockReturnValue(false);
delegate(mockElement, 'click', { delegate(mockElement, 'click', {
[`.${wrapperClass}`]: mockWrapperHandler, [`.${wrapperClass}`]: mockWrapperHandler,
[`.${parentClass}`]: mockParentHandler, [`.${parentClass}`]: mockParentHandler,

View file

@ -4,7 +4,6 @@ import { mockStorage } from '../../../test/mock-storage';
import { createEvent, fireEvent } from '@testing-library/dom'; import { createEvent, fireEvent } from '@testing-library/dom';
import { EventType } from '@testing-library/dom/types/events'; import { EventType } from '@testing-library/dom/types/events';
import { SpoilerType } from '../../../types/booru-object'; import { SpoilerType } from '../../../types/booru-object';
import { beforeEach } from 'vitest';
describe('Image utils', () => { describe('Image utils', () => {
const hiddenClass = 'hidden'; const hiddenClass = 'hidden';
@ -83,10 +82,6 @@ describe('Image utils', () => {
}, },
}); });
beforeEach(() => {
mockServeHidpiValue = null;
});
describe('video thumbnail', () => { describe('video thumbnail', () => {
type CreateMockElementsOptions = { type CreateMockElementsOptions = {
extension: string; extension: string;
@ -114,7 +109,7 @@ describe('Image utils', () => {
}); });
} }
mockElement.appendChild(mockVideo); mockElement.appendChild(mockVideo);
const playSpy = vi.spyOn(mockVideo, 'play').mockReturnValue(Promise.resolve()); const playSpy = jest.spyOn(mockVideo, 'play').mockReturnValue(Promise.resolve());
const mockSpoilerOverlay = createMockSpoilerOverlay(); const mockSpoilerOverlay = createMockSpoilerOverlay();
mockElement.appendChild(mockSpoilerOverlay); mockElement.appendChild(mockSpoilerOverlay);
@ -173,7 +168,7 @@ describe('Image utils', () => {
const { mockElement } = createMockElements({ const { mockElement } = createMockElements({
extension: 'webm', extension: 'webm',
}); });
const jsonParseSpy = vi.spyOn(JSON, 'parse'); const jsonParseSpy = jest.spyOn(JSON, 'parse');
mockElement.removeAttribute(missingAttributeName); mockElement.removeAttribute(missingAttributeName);
@ -387,7 +382,7 @@ describe('Image utils', () => {
it('should return early if picture AND video elements are missing', () => { it('should return early if picture AND video elements are missing', () => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
const querySelectorSpy = vi.spyOn(mockElement, 'querySelector'); const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
@ -404,9 +399,9 @@ describe('Image utils', () => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
const mockVideo = document.createElement('video'); const mockVideo = document.createElement('video');
mockElement.appendChild(mockVideo); mockElement.appendChild(mockVideo);
const pauseSpy = vi.spyOn(mockVideo, 'pause').mockReturnValue(undefined); const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
const querySelectorSpy = vi.spyOn(mockElement, 'querySelector'); const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
@ -428,7 +423,7 @@ describe('Image utils', () => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
const mockVideo = document.createElement('video'); const mockVideo = document.createElement('video');
mockElement.appendChild(mockVideo); mockElement.appendChild(mockVideo);
const pauseSpy = vi.spyOn(mockVideo, 'pause').mockReturnValue(undefined); const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
const mockImage = document.createElement('img'); const mockImage = document.createElement('img');
mockImage.classList.add(hiddenClass); mockImage.classList.add(hiddenClass);
mockElement.appendChild(mockImage); mockElement.appendChild(mockImage);
@ -457,8 +452,8 @@ describe('Image utils', () => {
const mockPicture = document.createElement('picture'); const mockPicture = document.createElement('picture');
mockElement.appendChild(mockPicture); mockElement.appendChild(mockPicture);
const imgQuerySelectorSpy = vi.spyOn(mockElement, 'querySelector'); const imgQuerySelectorSpy = jest.spyOn(mockElement, 'querySelector');
const pictureQuerySelectorSpy = vi.spyOn(mockPicture, 'querySelector'); const pictureQuerySelectorSpy = jest.spyOn(mockPicture, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason); hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
@ -498,7 +493,7 @@ describe('Image utils', () => {
describe('spoilerThumb', () => { describe('spoilerThumb', () => {
const testSpoilerThumb = (handlers?: [EventType, EventType]) => { const testSpoilerThumb = (handlers?: [EventType, EventType]) => {
const { mockElement, mockSpoilerOverlay, mockSizeImage } = createMockElementWithPicture('jpg'); const { mockElement, mockSpoilerOverlay, mockSizeImage } = createMockElementWithPicture('jpg');
const addEventListenerSpy = vi.spyOn(mockElement, 'addEventListener'); const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
spoilerThumb(mockElement, mockSpoilerUri, mockSpoilerReason); spoilerThumb(mockElement, mockSpoilerUri, mockSpoilerReason);

View file

@ -1,5 +1,5 @@
import { fetchHtml, fetchJson, handleError } from '../requests'; import { fetchHtml, fetchJson, handleError } from '../requests';
import { fetchMock } from '../../../test/fetch-mock.ts'; import fetchMock from 'jest-fetch-mock';
describe('Request utils', () => { describe('Request utils', () => {
const mockEndpoint = '/endpoint'; const mockEndpoint = '/endpoint';

View file

@ -117,11 +117,11 @@ describe('Store utilities', () => {
it('should attach a storage event listener and fire when the provide key changes', () => { it('should attach a storage event listener and fire when the provide key changes', () => {
const mockKey = `mock-watch-key-${getRandomIntBetween(1, 10)}`; const mockKey = `mock-watch-key-${getRandomIntBetween(1, 10)}`;
const mockValue = Math.random(); const mockValue = Math.random();
const mockCallback = vi.fn(); const mockCallback = jest.fn();
setStorageValue({ setStorageValue({
[mockKey]: JSON.stringify(mockValue), [mockKey]: JSON.stringify(mockValue),
}); });
const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const cleanup = store.watch(mockKey, mockCallback); const cleanup = store.watch(mockKey, mockCallback);

View file

@ -57,8 +57,8 @@ export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?:
if (attr) { if (attr) {
for (const prop in attr) { for (const prop in attr) {
const newValue = attr[prop]; const newValue = attr[prop];
if (newValue) { if (typeof newValue !== 'undefined') {
el[prop] = newValue; el[prop] = newValue as Exclude<typeof newValue, undefined>;
} }
} }
} }

8539
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,60 @@
{ {
"type": "module", "type": "module",
"scripts": { "scripts": {
"deploy": "cross-env NODE_ENV=production tsc && cross-env NODE_ENV=production vite build", "deploy": "cross-env NODE_ENV=production webpack",
"lint": "eslint . --ext .js,.ts", "lint": "eslint . --ext .js,.ts",
"test": "vitest run --coverage", "test": "jest --ci",
"test:watch": "vitest watch --coverage", "test:watch": "jest --watch",
"dev": "vite", "watch": "webpack --watch"
"build": "tsc && vite build",
"preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.3.0",
"@types/web": "^0.0.143", "@rollup/plugin-multi-entry": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^7.8.0", "@rollup/plugin-typescript": "^11.0.0",
"@typescript-eslint/parser": "^7.8.0", "@rollup/plugin-virtual": "^3.0.1",
"autoprefixer": "^10.4.19", "@types/web": "^0.0.91",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"acorn": "^8.8.2",
"autoprefixer": "^10.4.13",
"brunch": "^4.0.2",
"copy-webpack-plugin": "^11.0.0",
"copycat-brunch": "^1.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"jest-environment-jsdom": "^29.7.0", "eslint-webpack-plugin": "^4.0.0",
"normalize-scss": "^8.0.0", "file-loader": "^6.2.0",
"sass": "^1.75.0", "ignore-emit-webpack-plugin": "^2.0.6",
"typescript": "^5.4", "jest-environment-jsdom": "^29.4.3",
"vite": "^5.2" "mini-css-extract-plugin": "^2.7.2",
"normalize-scss": "^7.0.1",
"postcss": "^8.4.31",
"postcss-loader": "^7.2.4",
"postcss-scss": "^4.0.6",
"postcss-url": "^10.1.3",
"rollup": "^2.57.0",
"rollup-plugin-includepaths": "^0.2.4",
"sass": "^1.58.3",
"sass-loader": "^13.2.0",
"source-map-support": "^0.5.21",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"tslib": "^2.5.0",
"typescript": "^4.9",
"webpack": "^5.76.0",
"webpack-cli": "^5.0.1",
"webpack-rollup-loader": "^0.8.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.1.0", "@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^5.16.5",
"@types/chai-dom": "^1.11.3", "@types/jest": "^29.4.0",
"@vitest/coverage-v8": "^1.5.3", "eslint-plugin-jest": "^27.2.1",
"chai": "^5", "eslint-plugin-jest-dom": "^4.0.3",
"eslint-plugin-vitest": "^0.5.4", "jest": "^29.4.3",
"jsdom": "^24.0.0", "jest-fetch-mock": "^3.0.3",
"vitest": "^1.5.3", "ts-jest": "^29.1.0"
"vitest-fetch-mock": "^0.2.2"
} }
} }

View file

@ -1,4 +0,0 @@
import createFetchMock from 'vitest-fetch-mock';
import { vi } from 'vitest';
export const fetchMock = createFetchMock(vi);

21
assets/test/jest-setup.ts Normal file
View file

@ -0,0 +1,21 @@
import '@testing-library/jest-dom';
import { matchNone } from '../js/query/boolean';
window.booru = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
timeAgo: () => {},
csrfToken: 'mockCsrfToken',
hiddenTag: '/mock-tagblocked.svg',
hiddenTagList: [],
ignoredTagList: [],
imagesWithDownvotingDisabled: [],
spoilerType: 'off',
spoileredTagList: [],
userCanEditFilter: false,
userIsSignedIn: false,
watchedTagList: [],
hiddenFilter: matchNone(),
spoileredFilter: matchNone(),
interactions: [],
tagsVersion: 5
};

View file

@ -1,9 +1,9 @@
export function mockDateNow(initialDateNow: number): void { export function mockDateNow(initialDateNow: number): void {
beforeAll(() => { beforeAll(() => {
vi.useFakeTimers().setSystemTime(initialDateNow); jest.useFakeTimers().setSystemTime(initialDateNow);
}); });
afterAll(() => { afterAll(() => {
vi.useRealTimers(); jest.useRealTimers();
}); });
} }

View file

@ -1,11 +1,9 @@
import { MockInstance } from 'vitest';
type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem'; type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem';
export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: MockInstance } { export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: jest.SpyInstance } {
const getItemSpy = 'getItem' in options ? vi.spyOn(Storage.prototype, 'getItem') : undefined; const getItemSpy = 'getItem' in options ? jest.spyOn(Storage.prototype, 'getItem') : undefined;
const setItemSpy = 'setItem' in options ? vi.spyOn(Storage.prototype, 'setItem') : undefined; const setItemSpy = 'setItem' in options ? jest.spyOn(Storage.prototype, 'setItem') : undefined;
const removeItemSpy = 'removeItem' in options ? vi.spyOn(Storage.prototype, 'removeItem') : undefined; const removeItemSpy = 'removeItem' in options ? jest.spyOn(Storage.prototype, 'removeItem') : undefined;
beforeAll(() => { beforeAll(() => {
getItemSpy && getItemSpy.mockImplementation((options as Storage).getItem); getItemSpy && getItemSpy.mockImplementation((options as Storage).getItem);
@ -28,7 +26,7 @@ export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage,
return { getItemSpy, setItemSpy, removeItemSpy } as ReturnType<typeof mockStorage>; return { getItemSpy, setItemSpy, removeItemSpy } as ReturnType<typeof mockStorage>;
} }
type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: MockInstance } & { type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: jest.SpyInstance } & {
/** /**
* Forces the mock storage back to its default (empty) state * Forces the mock storage back to its default (empty) state
* @param value * @param value

View file

@ -1,35 +0,0 @@
import { matchNone } from '../js/query/boolean';
import '@testing-library/jest-dom/vitest';
import { URL } from 'node:url';
import { Blob } from 'node:buffer';
import { fireEvent } from '@testing-library/dom';
window.booru = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
timeAgo: () => {},
csrfToken: 'mockCsrfToken',
hiddenTag: '/mock-tagblocked.svg',
hiddenTagList: [],
ignoredTagList: [],
imagesWithDownvotingDisabled: [],
spoilerType: 'off',
spoileredTagList: [],
userCanEditFilter: false,
userIsSignedIn: false,
watchedTagList: [],
hiddenFilter: matchNone(),
spoileredFilter: matchNone(),
interactions: [],
tagsVersion: 5
};
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
// jsdom URL and Blob are missing most of the implementation
// Use the node version of these types instead
Object.assign(globalThis, { URL, Blob });
// Prevents an error when calling `form.submit()` directly in
// the code that is being tested
HTMLFormElement.prototype.submit = function() {
fireEvent.submit(this);
};

View file

@ -1,26 +1,16 @@
{ {
"compilerOptions": { "compilerOptions": {
"noEmit": true,
"baseUrl": "./js", "baseUrl": "./js",
"target": "ES2020", "target": "ES2018",
"useDefineForClassFields": true,
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "Node",
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": [ "lib": [
"ES2020", "ES2018",
"DOM", "DOM"
"DOM.Iterable"
], ],
"strict": true
"moduleResolution": "Node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"types": ["vitest/globals"]
} }
} }

View file

@ -1,97 +0,0 @@
/// <reference types="vitest" />
import fs from 'fs';
import path from 'path';
import autoprefixer from 'autoprefixer';
import { defineConfig, UserConfig, ConfigEnv } from 'vite';
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
const isDev = command !== 'build' && mode !== 'test';
if (isDev) {
// Terminate the watcher when Phoenix quits
// @see https://moroz.dev/blog/integrating-vite-js-with-phoenix-1-6
process.stdin.on('close', () => {
// eslint-disable-next-line no-process-exit
process.exit(0);
});
process.stdin.resume();
}
const themeNames =
fs.readdirSync(path.resolve(__dirname, 'css/themes/')).map(name => {
const m = name.match(/([-a-z]+).scss/);
if (m) { return m[1]; }
return null;
});
const themes = new Map();
for (const name of themeNames) {
themes.set(`css/${name}`, `./css/themes/${name}.scss`);
}
return {
publicDir: 'static',
plugins: [],
resolve: {
alias: {
common: path.resolve(__dirname, 'css/common/'),
views: path.resolve(__dirname, 'css/views/')
}
},
build: {
target: 'es2020',
outDir: path.resolve(__dirname, '../priv/static'),
emptyOutDir: false,
sourcemap: isDev,
manifest: false,
cssCodeSplit: true,
rollupOptions: {
input: {
'js/app': './js/app.js',
...Object.fromEntries(themes)
},
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
},
css: {
postcss: {
plugins: [autoprefixer]
}
},
test: {
globals: true,
environment: 'jsdom',
// TODO Jest --randomize CLI flag equivalent, consider enabling in the future
// sequence: { shuffle: true },
setupFiles: './test/vitest-setup.ts',
coverage: {
reporter: ['text', 'html'],
include: ['js/**/*.{js,ts}'],
exclude: [
'node_modules/',
'.*\\.test\\.ts$',
'.*\\.d\\.ts$',
],
thresholds: {
statements: 0,
branches: 0,
functions: 0,
lines: 0,
'**/utils/**/*.ts': {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
}
}
}
};
});

156
assets/webpack.config.js Normal file
View file

@ -0,0 +1,156 @@
import fs from 'fs';
import path from 'path';
import url from 'url';
import TerserPlugin from 'terser-webpack-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import CopyPlugin from 'copy-webpack-plugin';
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import autoprefixer from 'autoprefixer';
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
import rollupPluginMultiEntry from '@rollup/plugin-multi-entry';
import rollupPluginTypescript from '@rollup/plugin-typescript';
const isDevelopment = process.env.NODE_ENV !== 'production';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const includePaths = rollupPluginIncludepaths();
const multiEntry = rollupPluginMultiEntry();
const typescript = rollupPluginTypescript();
let plugins = [
new IgnoreEmitPlugin(/css\/.*(?<!css)$/),
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css'
}),
new CopyPlugin({
patterns: [
{ from: path.resolve(__dirname, 'static') },
],
}),
];
if (isDevelopment) {
plugins = plugins.concat([
new ESLintPlugin({
extensions: ['js', 'ts'],
failOnError: true,
failOnWarning: isDevelopment
})
]);
}
else {
plugins = plugins.concat([
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
]);
}
const themeNames =
fs.readdirSync(path.resolve(__dirname, 'css/themes')).map(name =>
name.match(/([-a-z]+).scss/)[1]
);
const themes = {};
for (const name of themeNames) {
themes[`css/${name}`] = `./css/themes/${name}.scss`;
}
export default {
mode: isDevelopment ? 'development' : 'production',
entry: {
'js/app.js': './js/app.js',
...themes
},
output: {
filename: '[name]',
path: path.resolve(__dirname, '../priv/static'),
},
optimization: {
minimize: !isDevelopment,
providedExports: true,
usedExports: true,
concatenateModules: true,
},
devtool: isDevelopment ? 'inline-source-map' : undefined,
performance: { hints: false },
resolve: {
alias: {
common: path.resolve(__dirname, 'css/common/'),
views: path.resolve(__dirname, 'css/views/')
}
},
module: {
rules: [
{
test: /\.(ttf|eot|svg|woff2?)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: './fonts',
publicPath: '../fonts',
},
dependency: { not: ['url'] },
},
{
test: /app\.js/,
use: [
{
loader: 'webpack-rollup-loader',
options: {
plugins: [
includePaths,
multiEntry,
typescript,
]
}
},
],
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
sourceMap: isDevelopment,
url: {
filter: (url, _resourcePath) => {
return !url.startsWith('/');
}
}
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
sourceMaps: isDevelopment,
ident: 'postcss',
syntax: 'postcss-scss',
plugins: [
autoprefixer(),
],
},
},
},
{
loader: 'sass-loader',
options: {
sourceMap: isDevelopment,
sassOptions: {
quietDeps: true
}
}
},
]
},
],
},
plugins,
};

View file

@ -8,7 +8,7 @@ config :philomena, Philomena.Repo, show_sensitive_data_on_connection_error: true
# #
# The watchers configuration can be used to run external # The watchers configuration can be used to run external
# watchers to your application. For example, we use it # watchers to your application. For example, we use it
# with vite to recompile .js and .css sources. # with webpack to recompile .js and .css sources.
config :philomena, PhilomenaWeb.Endpoint, config :philomena, PhilomenaWeb.Endpoint,
http: [port: 4000], http: [port: 4000],
debug_errors: true, debug_errors: true,
@ -16,23 +16,11 @@ config :philomena, PhilomenaWeb.Endpoint,
check_origin: false, check_origin: false,
watchers: [ watchers: [
node: [ node: [
"node_modules/vite/bin/vite.js", "node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--host",
"0.0.0.0",
"--config",
"vite.config.ts",
cd: Path.expand("../assets", __DIR__)
],
node: [
"node_modules/vite/bin/vite.js",
"build",
"--mode", "--mode",
"development", "development",
"--watch", "--watch",
"--config", "--watch-options-stdin",
"vite.config.ts",
cd: Path.expand("../assets", __DIR__) cd: Path.expand("../assets", __DIR__)
] ]
] ]

View file

@ -137,22 +137,10 @@ if config_env() == :prod do
url: [host: System.fetch_env!("APP_HOSTNAME"), scheme: "https", port: 443], url: [host: System.fetch_env!("APP_HOSTNAME"), scheme: "https", port: 443],
secret_key_base: System.fetch_env!("SECRET_KEY_BASE"), secret_key_base: System.fetch_env!("SECRET_KEY_BASE"),
server: not is_nil(System.get_env("START_ENDPOINT")) server: not is_nil(System.get_env("START_ENDPOINT"))
# Do not relax CSP in production
config :philomena, csp_relaxed: false
# Disable Vite HMR in prod
config :philomena, vite_reload: false
else else
# Don't send email in development # Don't send email in development
config :philomena, Philomena.Mailer, adapter: Bamboo.LocalAdapter config :philomena, Philomena.Mailer, adapter: Bamboo.LocalAdapter
# Use this to debug slime templates # Use this to debug slime templates
# config :slime, :keep_lines, true # config :slime, :keep_lines, true
# Relax CSP rules in development and test servers
config :philomena, csp_relaxed: true
# Enable Vite HMR
config :philomena, vite_reload: true
end end

View file

@ -51,8 +51,6 @@ services:
- postgres - postgres
- elasticsearch - elasticsearch
- redis - redis
ports:
- '5173:5173'
postgres: postgres:
image: postgres:16.2-alpine image: postgres:16.2-alpine

View file

@ -1,4 +1,4 @@
FROM elixir:1.16.2-alpine FROM elixir:1.16.1-alpine
ADD https://api.github.com/repos/philomena-dev/FFmpeg/git/refs/heads/release/6.1 /tmp/ffmpeg_version.json ADD https://api.github.com/repos/philomena-dev/FFmpeg/git/refs/heads/release/6.1 /tmp/ffmpeg_version.json
RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \ RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \
@ -24,5 +24,4 @@ COPY docker/app/run-test /usr/local/bin/run-test
COPY docker/app/safe-rsvg-convert /usr/local/bin/safe-rsvg-convert COPY docker/app/safe-rsvg-convert /usr/local/bin/safe-rsvg-convert
COPY docker/app/purge-cache /usr/local/bin/purge-cache COPY docker/app/purge-cache /usr/local/bin/purge-cache
ENV PATH=$PATH:/root/.cargo/bin ENV PATH=$PATH:/root/.cargo/bin
EXPOSE 5173
CMD run-development CMD run-development

View file

@ -3,7 +3,6 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
alias Philomena.Users.User alias Philomena.Users.User
alias Philomena.Images.Image alias Philomena.Images.Image
alias Philomena.Tags.Tag
alias Philomena.TagChanges.TagChange alias Philomena.TagChanges.TagChange
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query
@ -17,27 +16,19 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
tag_changes = tag_changes =
TagChange TagChange
|> join(:inner, [tc], i in Image, on: tc.image_id == i.id) |> join(:inner, [tc], i in Image, on: tc.image_id == i.id)
|> only_tag_join(params)
|> where( |> where(
[tc, i], [tc, i],
tc.user_id == ^user.id and not (i.user_id == ^user.id and i.anonymous == true) tc.user_id == ^user.id and not (i.user_id == ^user.id and i.anonymous == true)
) )
|> added_filter(params) |> added_filter(params)
|> only_tag_filter(params)
|> preload([:tag, :user, image: [:user, :sources, tags: :aliases]]) |> preload([:tag, :user, image: [:user, :sources, tags: :aliases]])
|> order_by(desc: :id) |> order_by(desc: :id)
|> Repo.paginate(conn.assigns.scrivener) |> Repo.paginate(conn.assigns.scrivener)
# params.permit(:added, :only_tag) ...
pagination_params =
[added: conn.params["added"], only_tag: conn.params["only_tag"]]
|> Keyword.filter(fn {_k, v} -> not is_nil(v) and v != "" end)
render(conn, "index.html", render(conn, "index.html",
title: "Tag Changes for User `#{user.name}'", title: "Tag Changes for User `#{user.name}'",
user: user, user: user,
tag_changes: tag_changes, tag_changes: tag_changes
pagination_params: pagination_params
) )
end end
@ -49,18 +40,4 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
defp added_filter(query, _params), defp added_filter(query, _params),
do: query do: query
defp only_tag_join(query, %{"only_tag" => only_tag})
when is_binary(only_tag) and only_tag != "",
do: join(query, :inner, [tc], t in Tag, on: tc.tag_id == t.id)
defp only_tag_join(query, _params),
do: query
defp only_tag_filter(query, %{"only_tag" => only_tag})
when is_binary(only_tag) and only_tag != "",
do: where(query, [_, _, t], t.name == ^only_tag)
defp only_tag_filter(query, _params),
do: query
end end

View file

@ -3,6 +3,7 @@ defmodule PhilomenaWeb.MarkdownRenderer do
alias Philomena.Images.Image alias Philomena.Images.Image
alias Philomena.Repo alias Philomena.Repo
alias PhilomenaWeb.ImageView alias PhilomenaWeb.ImageView
import Phoenix.HTML
import Phoenix.HTML.Link import Phoenix.HTML.Link
import Ecto.Query import Ecto.Query
@ -83,6 +84,7 @@ defmodule PhilomenaWeb.MarkdownRenderer do
size: ImageView.select_version(img, :medium), size: ImageView.select_version(img, :medium),
conn: conn conn: conn
) )
|> safe_to_string()
[_id, "t"] when not img.hidden_from_users and img.approved -> [_id, "t"] when not img.hidden_from_users and img.approved ->
Phoenix.View.render(ImageView, "_image_target.html", Phoenix.View.render(ImageView, "_image_target.html",
@ -91,6 +93,7 @@ defmodule PhilomenaWeb.MarkdownRenderer do
size: ImageView.select_version(img, :small), size: ImageView.select_version(img, :small),
conn: conn conn: conn
) )
|> safe_to_string()
[_id, "s"] when not img.hidden_from_users and img.approved -> [_id, "s"] when not img.hidden_from_users and img.approved ->
Phoenix.View.render(ImageView, "_image_target.html", Phoenix.View.render(ImageView, "_image_target.html",
@ -99,15 +102,18 @@ defmodule PhilomenaWeb.MarkdownRenderer do
size: ImageView.select_version(img, :thumb_small), size: ImageView.select_version(img, :thumb_small),
conn: conn conn: conn
) )
|> safe_to_string()
[_id, suffix] when not img.approved -> [_id, suffix] when not img.approved ->
">>#{img.id}#{suffix}#{link_suffix(img)}" ">>#{img.id}#{suffix}#{link_suffix(img)}"
[_id, ""] -> [_id, ""] ->
link(">>#{img.id}#{link_suffix(img)}", to: "/images/#{img.id}") link(">>#{img.id}#{link_suffix(img)}", to: "/images/#{img.id}")
|> safe_to_string()
[_id, suffix] when suffix in ["t", "s", "p"] -> [_id, suffix] when suffix in ["t", "s", "p"] ->
link(">>#{img.id}#{suffix}#{link_suffix(img)}", to: "/images/#{img.id}") link(">>#{img.id}#{suffix}#{link_suffix(img)}", to: "/images/#{img.id}")
|> safe_to_string()
# This condition should never trigger, but let's leave it here just in case. # This condition should never trigger, but let's leave it here just in case.
[id, suffix] -> [id, suffix] ->
@ -118,12 +124,7 @@ defmodule PhilomenaWeb.MarkdownRenderer do
">>#{text}" ">>#{text}"
end end
string_contents = [text, rendered]
rendered
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
[text, string_contents]
end) end)
|> Map.new(fn [id, html] -> {id, html} end) |> Map.new(fn [id, html] -> {id, html} end)
end end

View file

@ -24,9 +24,8 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
csp_config = [ csp_config = [
{:default_src, ["'self'"]}, {:default_src, ["'self'"]},
{:script_src, [default_script_src() | script_src]}, {:script_src, ["'self'" | script_src]},
{:connect_src, [default_connect_src()]}, {:style_src, ["'self'" | style_src]},
{:style_src, [default_style_src() | style_src]},
{:object_src, ["'none'"]}, {:object_src, ["'none'"]},
{:frame_ancestors, ["'none'"]}, {:frame_ancestors, ["'none'"]},
{:frame_src, frame_src || ["'none'"]}, {:frame_src, frame_src || ["'none'"]},
@ -42,13 +41,7 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
|> Enum.map(&cspify_element/1) |> Enum.map(&cspify_element/1)
|> Enum.join("; ") |> Enum.join("; ")
if conn.status == 500 and allow_relaxed_csp() do put_resp_header(conn, "content-security-policy", csp_value)
# Allow Plug.Debugger to function in this case
delete_resp_header(conn, "content-security-policy")
else
# Enforce CSP otherwise
put_resp_header(conn, "content-security-policy", csp_value)
end
end) end)
end end
@ -64,14 +57,6 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
defp cdn_uri, do: Application.get_env(:philomena, :cdn_host) |> to_uri() defp cdn_uri, do: Application.get_env(:philomena, :cdn_host) |> to_uri()
defp camo_uri, do: Application.get_env(:philomena, :camo_host) |> to_uri() defp camo_uri, do: Application.get_env(:philomena, :camo_host) |> to_uri()
defp vite_reload?, do: Application.get_env(:philomena, :vite_reload)
defp default_script_src, do: if(vite_reload?(), do: "'self' localhost:5173", else: "'self'")
defp default_connect_src,
do: if(vite_reload?(), do: "'self' localhost:5173 ws://localhost:5173", else: "'self'")
defp default_style_src, do: if(vite_reload?(), do: "'self' 'unsafe-inline'", else: "'self'")
defp to_uri(host) when host in [nil, ""], do: "" defp to_uri(host) when host in [nil, ""], do: ""
defp to_uri(host), do: URI.to_string(%URI{scheme: "https", host: host}) defp to_uri(host), do: URI.to_string(%URI{scheme: "https", host: host})
@ -84,6 +69,4 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
Enum.join([key | value], " ") Enum.join([key | value], " ")
end end
defp allow_relaxed_csp, do: Application.get_env(:philomena, :csp_relaxed, false)
end end

View file

@ -45,15 +45,13 @@ defmodule PhilomenaWeb.StatsUpdater do
distinct_creators: distinct_creators, distinct_creators: distinct_creators,
images_in_galleries: images_in_galleries images_in_galleries: images_in_galleries
) )
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
now = DateTime.utc_now() |> DateTime.truncate(:second) now = DateTime.utc_now() |> DateTime.truncate(:second)
static_page = %{ static_page = %{
title: "Statistics", title: "Statistics",
slug: "stats", slug: "stats",
body: result, body: Phoenix.HTML.safe_to_string(result),
created_at: now, created_at: now,
updated_at: now updated_at: now
} }

View file

@ -37,6 +37,6 @@ p
strong> Q: Do you host streams? strong> Q: Do you host streams?
| A: No, we cheat and just link to streams on Picarto since that's where (almost) everyone is already. This is simply a nice way to track streaming artists. | A: No, we cheat and just link to streams on Picarto since that's where (almost) everyone is already. This is simply a nice way to track streaming artists.
p p
strong> Q: How do I get my stream/a friend's stream/&lt;artist&gt;'s stream here? strong> Q: How do I get my stream/a friend's stream/<artist>'s stream here?
' A: Send a private message to a site administrator ' A: Send a private message to a site administrator
' with a link to the stream and the artist tag if applicable. ' with a link to the stream and the artist tag if applicable.

View file

@ -1,4 +1,4 @@
.hide-mobile.header__navigation .hide-mobile
.dropdown.header__dropdown .dropdown.header__dropdown
a.header__link href="/images" a.header__link href="/images"
| Images | Images

View file

@ -18,14 +18,9 @@ html lang="en"
meta name="theme-color" content="#618fc3" meta name="theme-color" content="#618fc3"
meta name="format-detection" content="telephone=no" meta name="format-detection" content="telephone=no"
= csrf_meta_tag() = csrf_meta_tag()
script type="text/javascript" src=Routes.static_path(@conn, "/js/app.js") async="async"
= if vite_reload?() do
script type="module" src="http://localhost:5173/@vite/client"
script type="module" src="http://localhost:5173/js/app.js"
- else
script type="text/javascript" src=Routes.static_path(@conn, "/js/app.js") async="async"
= render PhilomenaWeb.LayoutView, "_opengraph.html", assigns = render PhilomenaWeb.LayoutView, "_opengraph.html", assigns
body data-theme=theme_name(@current_user) data-vite-reload=to_string(vite_reload?()) body data-theme=theme_name(@current_user)
= render PhilomenaWeb.LayoutView, "_burger.html", assigns = render PhilomenaWeb.LayoutView, "_burger.html", assigns
#container class=container_class(@current_user) #container class=container_class(@current_user)
= render PhilomenaWeb.LayoutView, "_header.html", assigns = render PhilomenaWeb.LayoutView, "_header.html", assigns

View file

@ -4,16 +4,16 @@ h1
= @user.name = @user.name
- route = fn p -> Routes.profile_tag_change_path(@conn, :index, @user, p) end - route = fn p -> Routes.profile_tag_change_path(@conn, :index, @user, p) end
- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @tag_changes, route: route, conn: @conn, params: @pagination_params - params = if @conn.params["added"], do: [added: @conn.params["added"]]
- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @tag_changes, route: route, conn: @conn, params: params
.block .block
.block__header .block__header
= form_for @conn, Routes.profile_tag_change_path(@conn, :index, @user), [method: "get", enforce_utf8: false], fn f -> span.block__header__title
= text_input f, :only_tag, class: "input", placeholder: "Tag", title: "Only show this tag", autocapitalize: "none" | Display only:
= submit "Search", class: "button", title: "Search"
= link "Removed", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.merge(@pagination_params, added: 0)) = link "Removed", to: Routes.profile_tag_change_path(@conn, :index, @user, added: 0)
= link "Added", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.merge(@pagination_params, added: 1)) = link "Added", to: Routes.profile_tag_change_path(@conn, :index, @user, added: 1)
= link "All", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.delete(@pagination_params, :added)) = link "All", to: Routes.profile_tag_change_path(@conn, :index, @user)
= render PhilomenaWeb.TagChangeView, "index.html", conn: @conn, tag_changes: @tag_changes, pagination: pagination = render PhilomenaWeb.TagChangeView, "index.html", conn: @conn, tag_changes: @tag_changes, pagination: pagination

View file

@ -22,10 +22,6 @@ defmodule PhilomenaWeb.LayoutView do
Application.get_env(:philomena, :cdn_host) Application.get_env(:philomena, :cdn_host)
end end
def vite_reload? do
Application.get_env(:philomena, :vite_reload)
end
defp ignored_tag_list(nil), do: [] defp ignored_tag_list(nil), do: []
defp ignored_tag_list([]), do: [] defp ignored_tag_list([]), do: []
defp ignored_tag_list([{tag, _body, _dnp_entries}]), do: [tag.id] defp ignored_tag_list([{tag, _body, _dnp_entries}]), do: [tag.id]

View file

@ -103,8 +103,6 @@ defmodule PhilomenaWeb.TagView do
{tags, shipping, data} {tags, shipping, data}
end end
# This is a rendered template, so raw/1 has no effect on safety
# sobelow_skip ["XSS.Raw"]
defp render_quick_tags({tags, shipping, data}, conn) do defp render_quick_tags({tags, shipping, data}, conn) do
render(PhilomenaWeb.TagView, "_quick_tag_table.html", render(PhilomenaWeb.TagView, "_quick_tag_table.html",
tags: tags, tags: tags,
@ -112,8 +110,6 @@ defmodule PhilomenaWeb.TagView do
data: data, data: data,
conn: conn conn: conn
) )
|> Phoenix.HTML.Safe.to_iodata()
|> Phoenix.HTML.raw()
end end
defp names_in_tab("default", data) do defp names_in_tab("default", data) do