diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index dfdfab46..25558653 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -14,8 +14,19 @@ jobs: with: path: | _build + .cargo deps - key: ${{ runner.os }}-build-deps-${{ hashFiles('mix.lock') }} + key: ${{ runner.os }}-deps-2-${{ hashFiles('mix.lock') }} + + - name: Enable caching + run: | + # Disable volumes so caching can take effect + sed -i -Ee 's/- app_[a-z]+_data:.*$//g' docker-compose.yml + + # Make ourselves the owner + echo "RUN addgroup -g $(id -g) -S appgroup && adduser -u $(id -u) -S appuser -G appgroup" >> docker/app/Dockerfile + echo "USER appuser" >> docker/app/Dockerfile + echo "RUN mix local.hex --force && mix local.rebar --force" >> docker/app/Dockerfile - run: docker compose pull - run: docker compose build @@ -27,6 +38,18 @@ jobs: run: | docker compose run app mix sobelow --config docker compose run app mix deps.audit + + - name: Dialyzer + run: | + docker compose run app mix dialyzer + + typos: + name: 'Check for spelling errors' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@master + lint-and-test: name: 'JavaScript Linting and Unit Tests' runs-on: ubuntu-latest diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..898ad2e4 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,10 @@ +[default] +extend-ignore-re = [ + # Ignore development secret key. Production secret key should + # be in environment files and not checked into source control. + ".*secret_key_base.*", + + # Key constraints with encoded names + "fk_rails_[a-f0-9]+" +] + diff --git a/assets/js/__tests__/imagesclientside.spec.ts b/assets/js/__tests__/imagesclientside.spec.ts new file mode 100644 index 00000000..3f3feb88 --- /dev/null +++ b/assets/js/__tests__/imagesclientside.spec.ts @@ -0,0 +1,161 @@ +import { filterNode, initImagesClientside } from '../imagesclientside'; +import { parseSearch } from '../match_query'; +import { matchNone } from '../query/boolean'; +import { assertNotNull } from '../utils/assert'; +import { $ } from '../utils/dom'; + +describe('filterNode', () => { + beforeEach(() => { + window.booru.hiddenTagList = []; + window.booru.spoileredTagList = []; + window.booru.ignoredTagList = []; + window.booru.imagesWithDownvotingDisabled = []; + + window.booru.hiddenFilter = matchNone(); + window.booru.spoileredFilter = matchNone(); + }); + + function makeMediaContainer() { + const element = document.createElement('div'); + element.innerHTML = ` +
+
+ +
+ `; + return [ element, assertNotNull($('.js-spoiler-info-overlay', element)) ]; + } + + it('should show image media boxes not matching any filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + + filterNode(container); + expect(spoilerOverlay).not.toContainHTML('(Complex Filter)'); + expect(spoilerOverlay).not.toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1'); + }); + + it('should spoiler media boxes spoilered by a tag filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.spoileredTagList = [1]; + + filterNode(container); + expect(spoilerOverlay).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should spoiler media boxes spoilered by a complex filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.spoileredFilter = parseSearch('id:1'); + + filterNode(container); + expect(spoilerOverlay).toContainHTML('(Complex Filter)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide media boxes hidden by a tag filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.hiddenTagList = [1]; + + filterNode(container); + expect(spoilerOverlay).toContainHTML('[HIDDEN]'); + expect(spoilerOverlay).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide media boxes hidden by a complex filter', () => { + const [ container, spoilerOverlay ] = makeMediaContainer(); + window.booru.hiddenFilter = parseSearch('id:1'); + + filterNode(container); + expect(spoilerOverlay).toContainHTML('[HIDDEN]'); + expect(spoilerOverlay).toContainHTML('(Complex Filter)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + function makeImageBlock(): HTMLElement[] { + const element = document.createElement('div'); + element.innerHTML = ` +
+ + +
+ `; + return [ + element, + assertNotNull($('.image-filtered', element)), + assertNotNull($('.image-show', element)), + assertNotNull($('.filter-explanation', element)) + ]; + } + + it('should show image blocks not matching any filter', () => { + const [ container, imageFiltered, imageShow ] = makeImageBlock(); + + filterNode(container); + expect(imageFiltered).toHaveClass('hidden'); + expect(imageShow).not.toHaveClass('hidden'); + expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1'); + }); + + it('should spoiler image blocks spoilered by a tag filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.spoileredTagList = [1]; + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('spoilered by'); + expect(filterExplanation).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should spoiler image blocks spoilered by a complex filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.spoileredFilter = parseSearch('id:1'); + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('spoilered by'); + expect(filterExplanation).toContainHTML('complex tag expression'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide image blocks hidden by a tag filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.hiddenTagList = [1]; + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('hidden by'); + expect(filterExplanation).toContainHTML('(unknown tag)'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + + it('should hide image blocks hidden by a complex filter', () => { + const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock(); + window.booru.hiddenFilter = parseSearch('id:1'); + + filterNode(container); + expect(imageFiltered).not.toHaveClass('hidden'); + expect(imageShow).toHaveClass('hidden'); + expect(filterExplanation).toContainHTML('hidden by'); + expect(filterExplanation).toContainHTML('complex tag expression'); + expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); + }); + +}); + +describe('initImagesClientside', () => { + it('should initialize the imagesWithDownvotingDisabled array', () => { + initImagesClientside(); + expect(window.booru.imagesWithDownvotingDisabled).toEqual([]); + }); +}); diff --git a/assets/js/captcha.ts b/assets/js/captcha.ts index eef2f0b2..44d0d9b6 100644 --- a/assets/js/captcha.ts +++ b/assets/js/captcha.ts @@ -1,15 +1,14 @@ +import { assertNotNull } from './utils/assert'; import { delegate, leftClick } from './utils/events'; import { clearEl, makeEl } from './utils/dom'; function insertCaptcha(_event: Event, target: HTMLInputElement) { - const { parentElement, dataset: { sitekey } } = target; - - if (!parentElement) { return; } + const parentElement = assertNotNull(target.parentElement); const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true}); const frame = makeEl('div', {className: 'h-captcha'}); - frame.dataset.sitekey = sitekey; + frame.dataset.sitekey = target.dataset.sitekey; clearEl(parentElement); diff --git a/assets/js/duplicate_reports.ts b/assets/js/duplicate_reports.ts index 12eb8c2c..55cdfeb1 100644 --- a/assets/js/duplicate_reports.ts +++ b/assets/js/duplicate_reports.ts @@ -2,9 +2,10 @@ * Interactive behavior for duplicate reports. */ +import { assertNotNull } from './utils/assert'; import { $, $$ } from './utils/dom'; -function setupDupeReports() { +export function setupDupeReports() { const onion = $('.onion-skin__image'); const slider = $('.onion-skin__slider'); const swipe = $('.swipe__image'); @@ -30,16 +31,12 @@ function setupSwipe(swipe: SVGSVGElement) { } function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) { - const target = $('#target', onion); + const target = assertNotNull($('#target', onion)); function setOpacity() { - if (target) { - target.setAttribute('opacity', slider.value); - } + target.setAttribute('opacity', slider.value); } setOpacity(); slider.addEventListener('input', setOpacity); } - -export { setupDupeReports }; diff --git a/assets/js/fp.ts b/assets/js/fp.ts index 631cc26f..65f0c583 100644 --- a/assets/js/fp.ts +++ b/assets/js/fp.ts @@ -1,36 +1,39 @@ /** * FP version 4 * - * Not reliant on deprecated properties, - * and potentially more accurate at what it's supposed to do. + * Not reliant on deprecated properties, and potentially + * more accurate at what it's supposed to do. */ -import { $ } from './utils/dom'; import store from './utils/store'; -interface RealKeyboard { - getLayoutMap: () => Promise> -} +const storageKey = 'cached_ses_value'; -interface RealUserAgentData { - brands: [{brand: string, version: string}], - mobile: boolean, - platform: string, -} +declare global { + interface Keyboard { + getLayoutMap: () => Promise> + } -interface RealNavigator extends Navigator { - deviceMemory: number | null, - keyboard: RealKeyboard | null, - userAgentData: RealUserAgentData | null, + interface UserAgentData { + brands: [{brand: string, version: string}], + mobile: boolean, + platform: string, + } + + interface Navigator { + deviceMemory: number | undefined, + keyboard: Keyboard | undefined, + userAgentData: UserAgentData | undefined, + } } /** - * Creates a 53-bit long hash of a string. + * Creates a 53-bit non-cryptographic hash of a string. * - * @param {string} str The string to hash. - * @param {number} seed The seed to use for hash generation. - * @return {number} The resulting hash as a 53-bit number. - * @see {@link https://stackoverflow.com/a/8831937} + * @param str The string to hash. + * @param seed The seed to use for hash generation. + * @return The resulting hash as a 53-bit number. + * @see {@link https://stackoverflow.com/a/52171480} */ function cyrb53(str: string, seed: number = 0x16fe7b0a): number { let h1 = 0xdeadbeef ^ seed; @@ -50,67 +53,104 @@ function cyrb53(str: string, seed: number = 0x16fe7b0a): number { return 4294967296 * (2097151 & h2) + (h1 >>> 0); } -/** Creates a semi-unique string from browser attributes. +/** + * Get keyboard layout data from the navigator layout map. * - * @async - * @return {Promise} Hexadecimally encoded 53 bit number padded to 7 bytes. - */ -async function createFp(): Promise { - const nav = navigator as RealNavigator; - let kb = 'none'; - let mem = '1'; - let ua = 'none'; + * @return String containing layout map entries, or `none` when unavailable + */ +async function getKeyboardData(): Promise { + if (navigator.keyboard) { + const layoutMap = await navigator.keyboard.getLayoutMap(); - if (nav.keyboard) { - kb = Array.from((await nav.keyboard.getLayoutMap()).entries()).sort().map(e => `${e[0]}${e[1]}`).join(''); + return Array.from(layoutMap.entries()) + .sort() + .map(([k, v]) => `${k}${v}`) + .join(''); } - if (nav.deviceMemory) { - mem = nav.deviceMemory.toString(); + return 'none'; +} + +/** + * Get an approximation of memory available in gigabytes. + * + * @return String containing device memory data, or `1` when unavailable + */ +function getMemoryData(): string { + if (navigator.deviceMemory) { + return navigator.deviceMemory.toString(); } - if (nav.userAgentData) { - const uadata = nav.userAgentData; + return '1'; +} + +/** + * Get the "brands" of the user agent. + * + * For Chromium-based browsers this returns additional data like "Edge" or "Chrome" + * which may also contain additional data beyond the `userAgent` property. + * + * @return String containing brand data, or `none` when unavailable + */ +function getUserAgentBrands(): string { + const data = navigator.userAgentData; + + if (data) { let brands = 'none'; - if (uadata.brands && uadata.brands.length > 0) { - brands = uadata.brands.filter(e => !e.brand.match(/.*ot.*rand.*/gi)).map(e => `${e.brand}${e.version}`).join(''); + if (data.brands && data.brands.length > 0) { + // NB: Chromium implements GREASE protocol to prevent ossification of + // the "Not a brand" string - see https://stackoverflow.com/a/64443187 + brands = data.brands + .filter(e => !e.brand.match(/.*ot.*rand.*/gi)) + .map(e => `${e.brand}${e.version}`) + .sort() + .join(''); } - ua = `${brands}${uadata.mobile}${uadata.platform}`; + return `${brands}${data.mobile}${data.platform}`; } - let width: string | null = store.get('cached_rem_size'); - const body = $('body'); + return 'none'; +} - if (!width && body) { - const testElement = document.createElement('span'); - testElement.style.minWidth = '1rem'; - testElement.style.maxWidth = '1rem'; - testElement.style.position = 'absolute'; +/** + * Get the size in rems of the default body font. + * + * Causes a forced layout. Be sure to cache this value. + * + * @return String with the rem size + */ +function getFontRemSize(): string { + const testElement = document.createElement('span'); + testElement.style.minWidth = '1rem'; + testElement.style.maxWidth = '1rem'; + testElement.style.position = 'absolute'; - body.appendChild(testElement); + document.body.appendChild(testElement); - width = testElement.clientWidth.toString(); + const width = testElement.clientWidth.toString(); - body.removeChild(testElement); + document.body.removeChild(testElement); - store.set('cached_rem_size', width); - } - - if (!width) { - width = '0'; - } + return width; +} +/** + * Create a semi-unique string from browser attributes. + * + * @return Hexadecimally encoded 53 bit number padded to 7 bytes. + */ +async function createFp(): Promise { const prints: string[] = [ navigator.userAgent, navigator.hardwareConcurrency.toString(), navigator.maxTouchPoints.toString(), navigator.language, - kb, - mem, - ua, - width, + await getKeyboardData(), + getMemoryData(), + getUserAgentBrands(), + getFontRemSize(), screen.height.toString(), screen.width.toString(), @@ -121,43 +161,50 @@ async function createFp(): Promise { new Date().getTimezoneOffset().toString(), ]; - return cyrb53(prints.join('')).toString(16).padStart(14, '0'); + return cyrb53(prints.join('')) + .toString(16) + .padStart(14, '0'); +} + +/** + * Gets the existing `_ses` value from local storage or cookies. + * + * @return String `_ses` value or `null` + */ +function getSesValue(): string | null { + // Try storage + const storageValue: string | null = store.get(storageKey); + if (storageValue) { + return storageValue; + } + + // Try cookie + const match = document.cookie.match(/_ses=([a-f0-9]+)/); + if (match && match[1]) { + return match[1]; + } + + // Not found + return null; } /** * Sets the `_ses` cookie. * * If `cached_ses_value` is present in local storage, uses it to set the `_ses` cookie. - * Otherwise if the `_ses` cookie already exits, uses its value instead. - * Otherwise attempts to generate a new value for the `_ses` cookie - * based on various browser attributes. + * Otherwise, if the `_ses` cookie already exists, uses its value instead. + * Otherwise, attempts to generate a new value for the `_ses` cookie based on + * various browser attributes. * Failing all previous methods, sets the `_ses` cookie to a fallback value. - * - * @async */ export async function setSesCookie() { - let fp: string | null = store.get('cached_ses_value'); + let sesValue = getSesValue(); - if (!fp) { - const m = document.cookie.match(/_ses=([a-f0-9]+)/); - - if (m && m[1]) { - fp = m[1]; - } - } - - if (!fp || fp.charAt(0) !== 'd' || fp.length !== 15) { + if (!sesValue || sesValue.charAt(0) !== 'd' || sesValue.length !== 15) { // The prepended 'd' acts as a crude versioning mechanism. - try { - fp = `d${await createFp()}`; - } - // If it fails, use fakeprint "d015c342859dde3" as a last resort. - catch { - fp = 'd015c342859dde3'; - } - - store.set('cached_ses_value', fp); + sesValue = `d${await createFp()}`; + store.set(storageKey, sesValue); } - document.cookie = `_ses=${fp}; path=/; SameSite=Lax`; + document.cookie = `_ses=${sesValue}; path=/; SameSite=Lax`; } diff --git a/assets/js/galleries.ts b/assets/js/galleries.ts index 7866de74..386bb278 100644 --- a/assets/js/galleries.ts +++ b/assets/js/galleries.ts @@ -3,6 +3,7 @@ */ import { arraysEqual } from './utils/array'; +import { assertNotNull, assertNotUndefined } from './utils/assert'; import { $, $$ } from './utils/dom'; import { initDraggables } from './utils/draggable'; import { fetchJson } from './utils/requests'; @@ -11,14 +12,13 @@ export function setupGalleryEditing() { if (!$('.rearrange-button')) return; const [ rearrangeEl, saveEl ] = $$('.rearrange-button'); - const sortableEl = $('#sortable'); - const containerEl = $('.media-list'); - - if (!sortableEl || !containerEl || !saveEl || !rearrangeEl) { return; } + const sortableEl = assertNotNull($('#sortable')); + const containerEl = assertNotNull($('.js-resizable-media-container')); // Copy array - let oldImages = window.booru.galleryImages.slice(); - let newImages = window.booru.galleryImages.slice(); + const galleryImages = assertNotUndefined(window.booru.galleryImages); + let oldImages = galleryImages.slice(); + let newImages = galleryImages.slice(); initDraggables(); @@ -33,17 +33,17 @@ export function setupGalleryEditing() { sortableEl.classList.remove('editing'); containerEl.classList.remove('drag-container'); - newImages = $$('.image-container', containerEl).map(i => parseInt(i.dataset.imageId || '-1', 10)); + newImages = $$('.image-container', containerEl) + .map(i => parseInt(assertNotUndefined(i.dataset.imageId), 10)); // If nothing changed, don't bother. if (arraysEqual(newImages, oldImages)) return; - if (saveEl.dataset.reorderPath) { - fetchJson('PATCH', saveEl.dataset.reorderPath, { - image_ids: newImages, + const reorderPath = assertNotUndefined(saveEl.dataset.reorderPath); - // copy the array again so that we have the newly updated set - }).then(() => oldImages = newImages.slice()); - } + fetchJson('PATCH', reorderPath, { + image_ids: newImages, + // copy the array again so that we have the newly updated set + }).then(() => oldImages = newImages.slice()); }); } diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts index de0b7727..add108c9 100644 --- a/assets/js/imagesclientside.ts +++ b/assets/js/imagesclientside.ts @@ -2,131 +2,109 @@ * Client-side image filtering/spoilering. */ +import { assertNotUndefined } from './utils/assert'; import { $$, escapeHtml } from './utils/dom'; import { setupInteractions } from './interactions'; import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image'; import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag'; import { AstMatcher } from './query/types'; -import { assertNotUndefined } from './utils/assert'; -type RunFilterCallback = (img: HTMLDivElement, test: TagData[]) => void; +type CallbackType = 'tags' | 'complex'; +type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void; -function runFilter(img: HTMLDivElement, test: TagData[] | boolean, runCallback: RunFilterCallback) { - if (!test || typeof test !== 'boolean' && test.length === 0) { return false; } +function run( + img: HTMLDivElement, + tags: TagData[], + complex: AstMatcher, + runCallback: RunCallback +): boolean { + const hit = (() => { + // Check tags array first to provide more precise filter explanations + const hitTags = imageHitsTags(img, tags); + if (hitTags.length !== 0) { + runCallback(img, hitTags, 'tags'); + return true; + } - runCallback(img, test as TagData[]); + // No tags matched, try complex filter AST + const hitComplex = imageHitsComplex(img, complex); + if (hitComplex) { + runCallback(img, hitTags, 'complex'); + return true; + } - // I don't like this. - window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(img.dataset.imageId)); + // Nothing matched at all, image can be shown + return false; + })(); - return true; + if (hit) { + // Disallow negative interaction on image which is not visible + window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(img.dataset.imageId)); + } + + return hit; } -// --- +function bannerImage(tagsHit: TagData[]) { + if (tagsHit.length > 0) { + return tagsHit[0].spoiler_image_uri || window.booru.hiddenTag; + } -function filterThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) { - hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); + return window.booru.hiddenTag; } -function spoilerThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) { - spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); +// TODO: this approach is not suitable for translations because it depends on +// markup embedded in the page adjacent to this text + +/* eslint-disable indent */ + +function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` + : '[HIDDEN] (Complex Filter)'; + hideThumb(img, bannerImage(tagsHit), bannerText); } -function filterThumbComplex(img: HTMLDivElement) { - hideThumb(img, window.booru.hiddenTag, '[HIDDEN] (Complex Filter)'); +function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? displayTags(tagsHit) + : '(Complex Filter)'; + spoilerThumb(img, bannerImage(tagsHit), bannerText); } -function spoilerThumbComplex(img: HTMLDivElement) { - spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); +function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` + : 'This image was hidden by a complex tag expression in '; + spoilerBlock(img, bannerImage(tagsHit), bannerText); } -function filterBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { - spoilerBlock( - img, - tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, - `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by ` - ); +function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { + const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` + : 'This image was spoilered by a complex tag expression in '; + spoilerBlock(img, bannerImage(tagsHit), bannerText); } -function spoilerBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { - spoilerBlock( - img, - tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, - `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is spoilered by ` - ); -} +/* eslint-enable indent */ -function filterBlockComplex(img: HTMLDivElement) { - spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); -} - -function spoilerBlockComplex(img: HTMLDivElement) { - spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); -} - -// --- - -function thumbTagFilter(tags: TagData[], img: HTMLDivElement) { - return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); -} - -function thumbComplexFilter(complex: AstMatcher, img: HTMLDivElement) { - return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); -} - -function thumbTagSpoiler(tags: TagData[], img: HTMLDivElement) { - return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); -} - -function thumbComplexSpoiler(complex: AstMatcher, img: HTMLDivElement) { - return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); -} - -function blockTagFilter(tags: TagData[], img: HTMLDivElement) { - return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); -} - -function blockComplexFilter(complex: AstMatcher, img: HTMLDivElement) { - return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); -} - -function blockTagSpoiler(tags: TagData[], img: HTMLDivElement) { - return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); -} - -function blockComplexSpoiler(complex: AstMatcher, img: HTMLDivElement) { - return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); -} - -// --- - -function filterNode(node: Pick) { +export function filterNode(node: Pick) { const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); const { hiddenFilter, spoileredFilter } = window.booru; // Image thumb boxes with vote and fave buttons on them $$('.image-container', node) - .filter(img => !thumbTagFilter(hiddenTags, img)) - .filter(img => !thumbComplexFilter(hiddenFilter, img)) - .filter(img => !thumbTagSpoiler(spoileredTags, img)) - .filter(img => !thumbComplexSpoiler(spoileredFilter, img)) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped)) + .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped)) .forEach(img => showThumb(img)); // Individual image pages and images in posts/comments $$('.image-show-container', node) - .filter(img => !blockTagFilter(hiddenTags, img)) - .filter(img => !blockComplexFilter(hiddenFilter, img)) - .filter(img => !blockTagSpoiler(spoileredTags, img)) - .filter(img => !blockComplexSpoiler(spoileredFilter, img)) + .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped)) + .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped)) .forEach(img => showBlock(img)); } -function initImagesClientside() { +export function initImagesClientside() { window.booru.imagesWithDownvotingDisabled = []; // This fills the imagesWithDownvotingDisabled array filterNode(document); // Once the array is populated, we can initialize interactions setupInteractions(); } - -export { initImagesClientside, filterNode }; diff --git a/assets/js/pmwarning.ts b/assets/js/pmwarning.ts index 524f0f12..23772dff 100644 --- a/assets/js/pmwarning.ts +++ b/assets/js/pmwarning.ts @@ -4,9 +4,9 @@ * Warn users that their PM will be reviewed. */ -import { $ } from './utils/dom'; +import { $, hideEl, showEl } from './utils/dom'; -function warnAboutPMs() { +export function warnAboutPMs() { const textarea = $('.js-toolbar-input'); const warning = $('.js-hidden-warning'); const imageEmbedRegex = /!+\[/g; @@ -17,12 +17,10 @@ function warnAboutPMs() { const value = textarea.value; if (value.match(imageEmbedRegex)) { - warning.classList.remove('hidden'); + showEl(warning); } else if (!warning.classList.contains('hidden')) { - warning.classList.add('hidden'); + hideEl(warning); } }); } - -export { warnAboutPMs }; diff --git a/assets/js/settings.ts b/assets/js/settings.ts index db5c8e6e..9ff6b17f 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -2,15 +2,17 @@ * Settings. */ +import { assertNotNull, assertNotUndefined } from './utils/assert'; import { $, $$ } from './utils/dom'; import store from './utils/store'; export function setupSettings() { - if (!$('#js-setting-table')) return; + + if (!$('#js-setting-table')) return; const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]'); - const themeSelect = $('#user_theme'); - const styleSheet = $('head link[rel="stylesheet"]'); + const themeSelect = assertNotNull($('#user_theme')); + const styleSheet = assertNotNull($('head link[rel="stylesheet"]')); // Local settings localCheckboxes.forEach(checkbox => { @@ -22,9 +24,7 @@ export function setupSettings() { // Theme preview if (themeSelect) { themeSelect.addEventListener('change', () => { - if (styleSheet) { - styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath || '#'; - } + styleSheet.href = assertNotUndefined(themeSelect.options[themeSelect.selectedIndex].dataset.themePath); }); } } diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts index f648724d..a4eb36a0 100644 --- a/assets/js/shortcuts.ts +++ b/assets/js/shortcuts.ts @@ -4,9 +4,7 @@ import { $ } from './utils/dom'; -interface ShortcutKeycodes { - [key: string]: () => void -} +type ShortcutKeyMap = Record void>; function getHover(): string | null { const thumbBoxHover = $('.media-box:hover'); @@ -45,7 +43,7 @@ function isOK(event: KeyboardEvent): boolean { document.activeElement.tagName !== 'TEXTAREA'; } -const keyCodes: ShortcutKeycodes = { +const keyCodes: ShortcutKeyMap = { KeyJ() { click('.js-prev'); }, // J - go to previous image KeyI() { click('.js-up'); }, // I - go to index page KeyK() { click('.js-next'); }, // K - go to next image @@ -55,17 +53,16 @@ const keyCodes: ShortcutKeycodes = { KeyO() { openFullView(); }, // O - open original KeyV() { openFullViewNewTab(); }, // V - open original in a new tab KeyF() { // F - favourite image - /* Gotta use a "return" here and in the next function because eslint is silly */ - return getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`) - : click('.block__header a.interaction--fave'); + click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` + : '.block__header a.interaction--fave'); }, KeyU() { // U - upvote image - return getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`) - : click('.block__header a.interaction--upvote'); + click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` + : '.block__header a.interaction--upvote'); }, }; -function listenForKeys() { +export function listenForKeys() { document.addEventListener('keydown', (event: KeyboardEvent) => { if (isOK(event) && keyCodes[event.code]) { keyCodes[event.code](); @@ -73,5 +70,3 @@ function listenForKeys() { } }); } - -export { listenForKeys }; diff --git a/assets/js/sources.ts b/assets/js/sources.ts index f1fbb88a..1eb0afec 100644 --- a/assets/js/sources.ts +++ b/assets/js/sources.ts @@ -1,5 +1,7 @@ +import { assertNotNull } from './utils/assert'; import { $ } from './utils/dom'; import { inputDuplicatorCreator } from './input-duplicator'; +import '../types/ujs'; export interface TagSourceEvent extends CustomEvent { target: HTMLElement, @@ -14,19 +16,17 @@ function setupInputs() { }); } -function imageSourcesCreator() { +export function imageSourcesCreator() { setupInputs(); - document.addEventListener('fetchcomplete', (({ target, detail }: TagSourceEvent) => { - const sourceSauce = $('.js-sourcesauce'); + document.addEventListener('fetchcomplete', ({ target, detail }) => { + if (target.matches('#source-form')) { + const sourceSauce = assertNotNull($('.js-sourcesauce')); - if (sourceSauce && target && target.matches('#source-form')) { detail.text().then(text => { sourceSauce.outerHTML = text; setupInputs(); }); } - }) as EventListener); + }); } - -export { imageSourcesCreator }; diff --git a/assets/js/staffhider.ts b/assets/js/staffhider.ts index 75f4ed13..86741d78 100644 --- a/assets/js/staffhider.ts +++ b/assets/js/staffhider.ts @@ -4,12 +4,10 @@ * Hide staff elements if enabled in the settings. */ -import { $$ } from './utils/dom'; +import { $$, hideEl } from './utils/dom'; export function hideStaffTools() { if (window.booru.hideStaffTools === 'true') { - $$('.js-staff-action').forEach(el => { - el.classList.add('hidden'); - }); + $$('.js-staff-action').forEach(el => hideEl(el)); } } diff --git a/assets/js/tags.ts b/assets/js/tags.ts index c7be4c88..c3547f27 100644 --- a/assets/js/tags.ts +++ b/assets/js/tags.ts @@ -4,7 +4,7 @@ import { $$, showEl, hideEl } from './utils/dom'; import { assertNotUndefined } from './utils/assert'; -import { TagSourceEvent } from './sources'; +import '../types/ujs'; type TagDropdownActionFunction = () => void; type TagDropdownActionList = Record; @@ -19,8 +19,8 @@ function removeTag(tagId: number, list: number[]) { function createTagDropdown(tag: HTMLSpanElement) { const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru; - const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$('.tag__dropdown__link'); - const [ unwatched, watched, spoilered, hidden ] = $$('.tag__state'); + const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$('.tag__dropdown__link', tag); + const [ unwatched, watched, spoilered, hidden ] = $$('.tag__state', tag); const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10); const actions: TagDropdownActionList = { @@ -56,15 +56,14 @@ function createTagDropdown(tag: HTMLSpanElement) { if (userIsSignedIn && !userCanEditFilter) showEl(filter); - tag.addEventListener('fetchcomplete', ((event: TagSourceEvent) => { - const act = event.target.dataset.tagAction; - - if (act && actions[act]) { - actions[act](); - } - }) as EventListener); + tag.addEventListener('fetchcomplete', event => { + const act = assertNotUndefined(event.target.dataset.tagAction); + actions[act](); + }); } export function initTagDropdown() { - [].forEach.call($$('.tag.dropdown'), createTagDropdown); + for (const tagSpan of $$('.tag.dropdown')) { + createTagDropdown(tagSpan); + } } diff --git a/assets/js/tagsmisc.ts b/assets/js/tagsmisc.ts index b68ea8b3..09366386 100644 --- a/assets/js/tagsmisc.ts +++ b/assets/js/tagsmisc.ts @@ -2,42 +2,37 @@ * Tags Misc */ +import { assertType, assertNotNull } from './utils/assert'; import { $, $$ } from './utils/dom'; import store from './utils/store'; import { initTagDropdown } from './tags'; import { setupTagsInput, reloadTagsInput } from './tagsinput'; -import { TagSourceEvent } from './sources'; +import '../types/ujs'; -type TagInputActionFunction = (tagInput: HTMLTextAreaElement | null) => void -type TagInputActionList = { - save: TagInputActionFunction, - load: TagInputActionFunction, - clear: TagInputActionFunction, -} +type TagInputActionFunction = (tagInput: HTMLTextAreaElement) => void; +type TagInputActionList = Record; + +function tagInputButtons(event: MouseEvent) { + const target = assertType(event.target, HTMLElement); -function tagInputButtons({target}: PointerEvent) { const actions: TagInputActionList = { - save(tagInput: HTMLTextAreaElement | null) { - if (tagInput) store.set('tag_input', tagInput.value); + save(tagInput: HTMLTextAreaElement) { + store.set('tag_input', tagInput.value); }, - load(tagInput: HTMLTextAreaElement | null) { - if (!tagInput) { return; } - + load(tagInput: HTMLTextAreaElement) { // If entry 'tag_input' does not exist, try to use the current list tagInput.value = store.get('tag_input') || tagInput.value; reloadTagsInput(tagInput); }, - clear(tagInput: HTMLTextAreaElement | null) { - if (!tagInput) { return; } - + clear(tagInput: HTMLTextAreaElement) { tagInput.value = ''; reloadTagsInput(tagInput); }, }; - for (const action in actions) { - if (target && (target as HTMLElement).matches(`#tagsinput-${action}`)) { - actions[action as keyof TagInputActionList]($('image_tag_input')); + for (const [ name, action ] of Object.entries(actions)) { + if (target && target.matches(`#tagsinput-${name}`)) { + action(assertNotNull($('#image_tag_input'))); } } } @@ -49,10 +44,10 @@ function setupTags() { }); } -function updateTagSauce({target, detail}: TagSourceEvent) { - const tagSauce = $('.js-tagsauce'); +function updateTagSauce({ target, detail }: FetchcompleteEvent) { + if (target.matches('#tags-form')) { + const tagSauce = assertNotNull($('.js-tagsauce')); - if (tagSauce && target.matches('#tags-form')) { detail.text().then(text => { tagSauce.outerHTML = text; setupTags(); @@ -63,8 +58,8 @@ function updateTagSauce({target, detail}: TagSourceEvent) { function setupTagEvents() { setupTags(); - document.addEventListener('fetchcomplete', updateTagSauce as EventListener); - document.addEventListener('click', tagInputButtons as EventListener); + document.addEventListener('fetchcomplete', updateTagSauce); + document.addEventListener('click', tagInputButtons); } export { setupTagEvents }; diff --git a/assets/js/utils/__tests__/local-autocompleter.spec.ts b/assets/js/utils/__tests__/local-autocompleter.spec.ts index bc612a08..182e1308 100644 --- a/assets/js/utils/__tests__/local-autocompleter.spec.ts +++ b/assets/js/utils/__tests__/local-autocompleter.spec.ts @@ -28,12 +28,12 @@ describe('Local Autocompleter', () => { }); describe('instantiation', () => { - it('should be constructable with compatible data', () => { + it('should be constructible with compatible data', () => { const result = new LocalAutocompleter(mockData); expect(result).toBeInstanceOf(LocalAutocompleter); }); - it('should NOT be constructable with incompatible data', () => { + it('should NOT be constructible with incompatible data', () => { const versionDataOffset = 12; const mockIncompatibleDataArray = new Array(versionDataOffset).fill(0); // Set data version to 1 @@ -45,6 +45,8 @@ describe('Local Autocompleter', () => { }); describe('topK', () => { + const termStem = ['f', 'o'].join(''); + let localAc: LocalAutocompleter; beforeAll(() => { @@ -66,7 +68,7 @@ describe('Local Autocompleter', () => { }); it('should return suggestions sorted by image count', () => { - const result = localAc.topK('fo', defaultK); + const result = localAc.topK(termStem, defaultK); expect(result).toEqual([ expect.objectContaining({ name: 'forest', imageCount: 3 }), expect.objectContaining({ name: 'fog', imageCount: 1 }), @@ -82,13 +84,13 @@ describe('Local Autocompleter', () => { }); it('should return only the required number of suggestions', () => { - const result = localAc.topK('fo', 1); + const result = localAc.topK(termStem, 1); expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]); }); it('should NOT return suggestions associated with hidden tags', () => { window.booru.hiddenTagList = [1]; - const result = localAc.topK('fo', defaultK); + const result = localAc.topK(termStem, defaultK); expect(result).toEqual([]); }); diff --git a/assets/js/when-ready.ts b/assets/js/when-ready.ts index 03cfee2a..07462efb 100644 --- a/assets/js/when-ready.ts +++ b/assets/js/when-ready.ts @@ -2,7 +2,7 @@ * Functions to execute when the DOM is ready */ -import { whenReady } from './utils/dom'; +import { whenReady } from './utils/dom'; import { listenAutocomplete } from './autocomplete'; import { loadBooruData } from './booru'; diff --git a/assets/package-lock.json b/assets/package-lock.json index 5312c490..80e43891 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -455,9 +455,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -470,9 +470,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -485,9 +485,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -500,9 +500,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -515,9 +515,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -545,9 +545,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -560,9 +560,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -575,9 +575,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -590,9 +590,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -605,9 +605,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -620,9 +620,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -635,9 +635,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -650,9 +650,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -665,9 +665,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -680,9 +680,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -710,9 +710,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -725,9 +725,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -740,9 +740,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -755,9 +755,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -770,9 +770,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -818,11 +818,11 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.15.1.tgz", - "integrity": "sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.16.0.tgz", + "integrity": "sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg==", "dependencies": { - "@eslint/object-schema": "^2.1.3", + "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -853,9 +853,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.4.0.tgz", - "integrity": "sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.5.0.tgz", + "integrity": "sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w==", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1409,9 +1409,9 @@ } }, "node_modules/@types/node": { - "version": "20.14.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", - "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", + "version": "20.14.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz", + "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==", "dependencies": { "undici-types": "~5.26.4" } @@ -1931,9 +1931,9 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead" }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", + "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", "bin": { "acorn": "bin/acorn" }, @@ -1959,9 +1959,12 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -2178,9 +2181,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001632", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz", - "integrity": "sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg==", + "version": "1.0.30001636", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", "funding": [ { "type": "opencollective", @@ -2526,9 +2529,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.799", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.799.tgz", - "integrity": "sha512-3D3DwWkRTzrdEpntY0hMLYwj7SeBk1138CkPE8sBDSj3WzrzOiG2rHm3luw8jucpf+WiyLBCZyU9lMHyQI9M9Q==" + "version": "1.4.810", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.810.tgz", + "integrity": "sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -2566,9 +2569,9 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2577,29 +2580,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -2642,15 +2645,15 @@ } }, "node_modules/eslint": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.4.0.tgz", - "integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.5.0.tgz", + "integrity": "sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/config-array": "^0.15.1", + "@eslint/config-array": "^0.16.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.4.0", + "@eslint/js": "9.5.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2662,7 +2665,7 @@ "eslint-scope": "^8.0.1", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.1", - "esquery": "^1.4.2", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2688,7 +2691,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" } }, "node_modules/eslint-plugin-vitest": { @@ -2716,13 +2719,13 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/scope-manager": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.0.tgz", - "integrity": "sha512-ZrMCe1R6a01T94ilV13egvcnvVJ1pxShkE0+NDjDzH4nvG1wXpwsVI5bZCvE7AEDH1mXEx5tJSVR68bLgG7Dng==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz", + "integrity": "sha512-adbXNVEs6GmbzaCpymHQ0MB6E4TqoiVbC0iqG3uijR8ZYfpAXMGttouQzF4Oat3P2GxDVIrg7bMI/P65LiQZdg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/visitor-keys": "7.13.0" + "@typescript-eslint/types": "7.13.1", + "@typescript-eslint/visitor-keys": "7.13.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2733,9 +2736,9 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.0.tgz", - "integrity": "sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.1.tgz", + "integrity": "sha512-7K7HMcSQIAND6RBL4kDl24sG/xKM13cA85dc7JnmQXw2cBDngg7c19B++JzvJHRG3zG36n9j1i451GBzRuHchw==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2746,13 +2749,13 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.0.tgz", - "integrity": "sha512-cAvBvUoobaoIcoqox1YatXOnSl3gx92rCZoMRPzMNisDiM12siGilSM4+dJAekuuHTibI2hVC2fYK79iSFvWjw==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.1.tgz", + "integrity": "sha512-uxNr51CMV7npU1BxZzYjoVz9iyjckBduFBP0S5sLlh1tXYzHzgZ3BR9SVsNed+LmwKrmnqN3Kdl5t7eZ5TS1Yw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/visitor-keys": "7.13.0", + "@typescript-eslint/types": "7.13.1", + "@typescript-eslint/visitor-keys": "7.13.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2774,15 +2777,15 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.0.tgz", - "integrity": "sha512-jceD8RgdKORVnB4Y6BqasfIkFhl4pajB1wVxrF4akxD2QPM8GNYjgGwEzYS+437ewlqqrg7Dw+6dhdpjMpeBFQ==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.1.tgz", + "integrity": "sha512-h5MzFBD5a/Gh/fvNdp9pTfqJAbuQC4sCN2WzuXme71lqFJsZtLbjxfSk4r3p02WIArOF9N94pdsLiGutpDbrXQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.13.0", - "@typescript-eslint/types": "7.13.0", - "@typescript-eslint/typescript-estree": "7.13.0" + "@typescript-eslint/scope-manager": "7.13.1", + "@typescript-eslint/types": "7.13.1", + "@typescript-eslint/typescript-estree": "7.13.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2796,12 +2799,12 @@ } }, "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.0.tgz", - "integrity": "sha512-nxn+dozQx+MK61nn/JP+M4eCkHDSxSLDpgE3WcQo0+fkjEolnaB5jswvIKC4K56By8MMgIho7f1PVxERHEo8rw==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.1.tgz", + "integrity": "sha512-k/Bfne7lrP7hcb7m9zSsgcBmo+8eicqqfNAJ7uUY+jkTFpKeH2FSkWpFRtimBxgkyvqfu9jTPRbYOvud6isdXA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/types": "7.13.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2875,11 +2878,11 @@ } }, "node_modules/espree": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", - "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", "dependencies": { - "acorn": "^8.11.3", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.0.0" }, @@ -5111,24 +5114,44 @@ } }, "node_modules/stylelint-config-recommended": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.0.tgz", - "integrity": "sha512-jSkx290CglS8StmrLp2TxAppIajzIBZKYm3IxT89Kg6fGlxbPiTiyH9PS5YUuVAFwaJLl1ikiXX0QWjI0jmgZQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", + "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], "engines": { "node": ">=18.12.0" }, "peerDependencies": { - "stylelint": "^16.0.0" + "stylelint": "^16.1.0" } }, "node_modules/stylelint-config-standard": { - "version": "36.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.0.tgz", - "integrity": "sha512-3Kjyq4d62bYFp/Aq8PMKDwlgUyPU4nacXsjDLWJdNPRUgpuxALu1KnlAHIj36cdtxViVhXexZij65yM0uNIHug==", + "version": "36.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz", + "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], "dependencies": { - "stylelint-config-recommended": "^14.0.0" + "stylelint-config-recommended": "^14.0.1" }, "engines": { "node": ">=18.12.0" @@ -5428,9 +5451,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5533,11 +5556,11 @@ "dev": true }, "node_modules/vite": { - "version": "5.2.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", - "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz", + "integrity": "sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==", "dependencies": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "postcss": "^8.4.38", "rollup": "^4.13.0" }, @@ -5869,9 +5892,9 @@ } }, "node_modules/ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts index a8d45874..f6de62b6 100644 --- a/assets/test/vitest-setup.ts +++ b/assets/test/vitest-setup.ts @@ -9,6 +9,7 @@ window.booru = { csrfToken: 'mockCsrfToken', hiddenTag: '/mock-tagblocked.svg', hiddenTagList: [], + hideStaffTools: 'true', ignoredTagList: [], imagesWithDownvotingDisabled: [], spoilerType: 'off', @@ -20,7 +21,6 @@ window.booru = { spoileredFilter: matchNone(), interactions: [], tagsVersion: 5, - hideStaffTools: 'false', galleryImages: [] }; diff --git a/assets/types/booru-object.d.ts b/assets/types/booru-object.d.ts index e23e7bce..4154385c 100644 --- a/assets/types/booru-object.d.ts +++ b/assets/types/booru-object.d.ts @@ -72,7 +72,7 @@ interface BooruObject { /** * List of image IDs in the current gallery. */ - galleryImages: number[] + galleryImages?: number[]; } declare global { diff --git a/assets/types/ujs.ts b/assets/types/ujs.ts new file mode 100644 index 00000000..f9cb88f5 --- /dev/null +++ b/assets/types/ujs.ts @@ -0,0 +1,11 @@ +export {}; + +declare global { + interface FetchcompleteEvent extends CustomEvent { + target: HTMLElement, + } + + interface GlobalEventHandlersEventMap { + fetchcomplete: FetchcompleteEvent; + } +} diff --git a/config/config.exs b/config/config.exs index 9d943587..c44f3bf0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -31,6 +31,7 @@ config :canary, # Configures the endpoint config :philomena, PhilomenaWeb.Endpoint, + adapter: Bandit.PhoenixAdapter, url: [host: "localhost"], secret_key_base: "xZYTon09JNRrj8snd7KL31wya4x71jmo5aaSSRmw1dGjWLRmEwWMTccwxgsGFGjM", render_errors: [view: PhilomenaWeb.ErrorView, accepts: ~w(html json)], @@ -46,8 +47,6 @@ config :phoenix, :template_engines, slime: PhoenixSlime.Engine, slimleex: PhoenixSlime.LiveViewEngine -config :tesla, adapter: Tesla.Adapter.Mint - # Configures Elixir's Logger config :logger, :console, format: "$time $metadata[$level] $message\n", diff --git a/config/runtime.exs b/config/runtime.exs index cf7736fd..83a927da 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -73,8 +73,7 @@ config :philomena, :s3_primary_options, host: System.fetch_env!("S3_HOST"), port: System.fetch_env!("S3_PORT"), access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), - secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"), - http_opts: [timeout: 180_000, recv_timeout: 180_000] + secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") config :philomena, :s3_primary_bucket, System.fetch_env!("S3_BUCKET") @@ -84,8 +83,7 @@ config :philomena, :s3_secondary_options, host: System.get_env("ALT_S3_HOST"), port: System.get_env("ALT_S3_PORT"), access_key_id: System.get_env("ALT_AWS_ACCESS_KEY_ID"), - secret_access_key: System.get_env("ALT_AWS_SECRET_ACCESS_KEY"), - http_opts: [timeout: 180_000, recv_timeout: 180_000] + secret_access_key: System.get_env("ALT_AWS_SECRET_ACCESS_KEY") config :philomena, :s3_secondary_bucket, System.get_env("ALT_S3_BUCKET") @@ -93,11 +91,7 @@ config :philomena, :s3_secondary_bucket, System.get_env("ALT_S3_BUCKET") config :elastix, httpoison_options: [ssl: [verify: :verify_none]] -config :ex_aws, :hackney_opts, - timeout: 180_000, - recv_timeout: 180_000, - use_default_pool: false, - pool: false +config :ex_aws, http_client: PhilomenaMedia.Req config :ex_aws, :retries, max_attempts: 20, diff --git a/docker-compose.yml b/docker-compose.yml index b4d57ea2..369705b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,7 @@ services: - '5173:5173' postgres: - image: postgres:16.2-alpine + image: postgres:16.3-alpine environment: - POSTGRES_PASSWORD=postgres volumes: @@ -86,7 +86,7 @@ services: driver: "none" files: - image: andrewgaul/s3proxy:sha-ec12ae0 + image: andrewgaul/s3proxy:sha-4175022 environment: - JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3 volumes: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index fb76abbc..b577bd78 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,4 +1,4 @@ -FROM elixir:1.16.2-alpine +FROM elixir:1.17-alpine 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 \ diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 60f8e21d..778f7609 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,4 +1,4 @@ -FROM openresty/openresty:1.25.3.1-2-alpine +FROM openresty/openresty:1.25.3.1-4-alpine ARG APP_DIR ARG S3_SCHEME ARG S3_HOST diff --git a/lib/philomena/application.ex b/lib/philomena/application.ex index 4d1a7a4b..f85de948 100644 --- a/lib/philomena/application.ex +++ b/lib/philomena/application.ex @@ -32,10 +32,7 @@ defmodule Philomena.Application do PhilomenaWeb.AdvertUpdater, PhilomenaWeb.UserFingerprintUpdater, PhilomenaWeb.UserIpUpdater, - PhilomenaWeb.Endpoint, - - # Connection drainer for SIGTERM - {Plug.Cowboy.Drainer, refs: [PhilomenaWeb.Endpoint.HTTP]} + PhilomenaWeb.Endpoint ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/philomena/artist_links/automatic_verifier.ex b/lib/philomena/artist_links/automatic_verifier.ex index 57fd8fd2..f2a5bebd 100644 --- a/lib/philomena/artist_links/automatic_verifier.ex +++ b/lib/philomena/artist_links/automatic_verifier.ex @@ -12,7 +12,7 @@ defmodule Philomena.ArtistLinks.AutomaticVerifier do end end - defp contains_verification_code?({:ok, %Tesla.Env{body: body, status: 200}}, code) do + defp contains_verification_code?({:ok, %{body: body, status: 200}}, code) do String.contains?(body, code) end diff --git a/lib/philomena/attribution.ex b/lib/philomena/attribution.ex index 53fca736..94d6c35e 100644 --- a/lib/philomena/attribution.ex +++ b/lib/philomena/attribution.ex @@ -1,22 +1,22 @@ defprotocol Philomena.Attribution do @doc """ - Provides the "parent object" identifier for this object. This is so - that anonymous posts under the same topic id can return the same hash - for the same user. + Provides the "parent object" identifier for this object. This is so + that anonymous posts under the same topic id can return the same hash + for the same user. """ @spec object_identifier(struct()) :: String.t() def object_identifier(object) @doc """ - Provides the "best" user identifier for an object. Usually this will be - the user_id, but may also be the fingerprint or IP address if other - information is unavailable. + Provides the "best" user identifier for an object. Usually this will be + the user_id, but may also be the fingerprint or IP address if other + information is unavailable. """ @spec best_user_identifier(struct()) :: String.t() def best_user_identifier(object) @doc """ - Return whether this object is considered to be anonymous. + Return whether this object is considered to be anonymous. """ @spec anonymous?(struct()) :: true | false def anonymous?(object) diff --git a/lib/philomena/channels/picarto_channel.ex b/lib/philomena/channels/picarto_channel.ex index a27a3615..1eacb28f 100644 --- a/lib/philomena/channels/picarto_channel.ex +++ b/lib/philomena/channels/picarto_channel.ex @@ -6,7 +6,7 @@ defmodule Philomena.Channels.PicartoChannel do @api_online |> PhilomenaProxy.Http.get() |> case do - {:ok, %Tesla.Env{body: body, status: 200}} -> + {:ok, %{body: body, status: 200}} -> body |> Jason.decode!() |> Map.new(&{&1["name"], fetch(&1, now)}) diff --git a/lib/philomena/channels/piczel_channel.ex b/lib/philomena/channels/piczel_channel.ex index 56da9e34..817dd486 100644 --- a/lib/philomena/channels/piczel_channel.ex +++ b/lib/philomena/channels/piczel_channel.ex @@ -6,7 +6,7 @@ defmodule Philomena.Channels.PiczelChannel do @api_online |> PhilomenaProxy.Http.get() |> case do - {:ok, %Tesla.Env{body: body, status: 200}} -> + {:ok, %{body: body, status: 200}} -> body |> Jason.decode!() |> Map.new(&{&1["slug"], fetch(&1, now)}) diff --git a/lib/philomena/comments/query.ex b/lib/philomena/comments/query.ex index 6b2bea42..9e9c8986 100644 --- a/lib/philomena/comments/query.ex +++ b/lib/philomena/comments/query.ex @@ -88,7 +88,7 @@ defmodule Philomena.Comments.Query do defp parse(fields, context, query_string) do fields - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string, context) end diff --git a/lib/philomena/filters/query.ex b/lib/philomena/filters/query.ex index adf53b09..3b6bb3ef 100644 --- a/lib/philomena/filters/query.ex +++ b/lib/philomena/filters/query.ex @@ -29,7 +29,7 @@ defmodule Philomena.Filters.Query do defp parse(fields, context, query_string) do fields - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string, context) end diff --git a/lib/philomena/galleries/query.ex b/lib/philomena/galleries/query.ex index 9baad469..e04ceecc 100644 --- a/lib/philomena/galleries/query.ex +++ b/lib/philomena/galleries/query.ex @@ -18,7 +18,7 @@ defmodule Philomena.Galleries.Query do query_string = query_string || "" fields() - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string) end end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 95788ad5..d68595fb 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -937,7 +937,7 @@ defmodule Philomena.Images do (source.sources ++ target.sources) |> Enum.map(fn s -> %Source{image_id: target.id, source: s.source} end) |> Enum.uniq() - |> Enum.take(10) + |> Enum.take(15) target |> Image.sources_changeset(sources) diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index d8e791c8..7b808eaa 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -212,11 +212,13 @@ defmodule Philomena.Images.Image do image |> cast(attrs, []) |> SourceDiffer.diff_input(old_sources, new_sources) + |> validate_length(:sources, max: 15) end def sources_changeset(image, new_sources) do change(image) |> put_assoc(:sources, new_sources) + |> validate_length(:sources, max: 15) end def tag_changeset(image, attrs, old_tags, new_tags, excluded_tags \\ []) do diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex index 575c6e71..9eedcd74 100644 --- a/lib/philomena/images/query.ex +++ b/lib/philomena/images/query.ex @@ -140,7 +140,7 @@ defmodule Philomena.Images.Query do defp parse(fields, context, query_string) do fields - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string, context) end diff --git a/lib/philomena/posts/query.ex b/lib/philomena/posts/query.ex index 27773776..331655c7 100644 --- a/lib/philomena/posts/query.ex +++ b/lib/philomena/posts/query.ex @@ -86,7 +86,7 @@ defmodule Philomena.Posts.Query do defp parse(fields, context, query_string) do fields - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string, context) end diff --git a/lib/philomena/reports/query.ex b/lib/philomena/reports/query.ex index d5adc2cc..c9d9be44 100644 --- a/lib/philomena/reports/query.ex +++ b/lib/philomena/reports/query.ex @@ -16,7 +16,7 @@ defmodule Philomena.Reports.Query do def compile(query_string) do fields() - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string || "", %{}) end end diff --git a/lib/philomena/tags.ex b/lib/philomena/tags.ex index 1f6e80c6..de7a0171 100644 --- a/lib/philomena/tags.ex +++ b/lib/philomena/tags.ex @@ -251,7 +251,7 @@ defmodule Philomena.Tags do |> where(tag_id: ^tag.id) |> Repo.delete_all() - # Update other assocations + # Update other associations ArtistLink |> where(tag_id: ^tag.id) |> Repo.update_all(set: [tag_id: target_tag.id]) diff --git a/lib/philomena/tags/query.ex b/lib/philomena/tags/query.ex index 5bfd2126..da148da4 100644 --- a/lib/philomena/tags/query.ex +++ b/lib/philomena/tags/query.ex @@ -19,7 +19,7 @@ defmodule Philomena.Tags.Query do def compile(query_string) do fields() - |> Parser.parser() + |> Parser.new() |> Parser.parse(query_string || "") end end diff --git a/lib/philomena/users/user_notifier.ex b/lib/philomena/users/user_notifier.ex index 48083321..2cd85d86 100644 --- a/lib/philomena/users/user_notifier.ex +++ b/lib/philomena/users/user_notifier.ex @@ -88,7 +88,7 @@ defmodule Philomena.Users.UserNotifier do Your account has been automatically locked due to too many attempts to sign in. - You can unlock your account by visting the URL below: + You can unlock your account by visiting the URL below: #{url} diff --git a/lib/philomena_media/analyzers.ex b/lib/philomena_media/analyzers.ex index a010916f..efa49d9a 100644 --- a/lib/philomena_media/analyzers.ex +++ b/lib/philomena_media/analyzers.ex @@ -54,7 +54,8 @@ defmodule PhilomenaMedia.Analyzers do :error = Analyzers.analyze(file) """ - @spec analyze(Plug.Upload.t() | Path.t()) :: {:ok, Result.t()} | :error + @spec analyze(Plug.Upload.t() | Path.t()) :: + {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error def analyze(%Plug.Upload{path: path}), do: analyze(path) def analyze(path) when is_binary(path) do diff --git a/lib/philomena_media/analyzers/analyzer.ex b/lib/philomena_media/analyzers/analyzer.ex index cf3b28ec..c96f0005 100644 --- a/lib/philomena_media/analyzers/analyzer.ex +++ b/lib/philomena_media/analyzers/analyzer.ex @@ -1,5 +1,8 @@ defmodule PhilomenaMedia.Analyzers.Analyzer do @moduledoc false + @doc """ + Generate a `m:PhilomenaMedia.Analyzers.Result` for file at the given path. + """ @callback analyze(Path.t()) :: PhilomenaMedia.Analyzers.Result.t() end diff --git a/lib/philomena_media/gif_preview.ex b/lib/philomena_media/gif_preview.ex new file mode 100644 index 00000000..f1c6fce6 --- /dev/null +++ b/lib/philomena_media/gif_preview.ex @@ -0,0 +1,117 @@ +defmodule PhilomenaMedia.GifPreview do + @moduledoc """ + GIF preview generation for video files. + """ + + @type duration :: float() + @type dimensions :: {pos_integer(), pos_integer()} + + @type num_images :: integer() + @type target_framerate :: 1..50 + @type opts :: [ + num_images: num_images(), + target_framerate: target_framerate() + ] + + @doc """ + Generate a GIF preview of the given video input with evenly-spaced sample points. + + The input should have pre-computed duration `duration`. The `dimensions` + are a `{target_width, target_height}` tuple of the largest dimensions desired, + and the image will be resized to fit inside the box of those dimensions, + preserving aspect ratio. + + Depending on the input file, this may take a long time to process. + + Options: + - `:target_framerate` - framerate of the output GIF, must be between 1 and 50. + Default 2. + - `:num_images` - number of images to sample from the video. + Default is determined by the duration: + * 90 or above: 20 images + * 30 or above: 10 images + * 1 or above: 5 images + * otherwise: 2 images + """ + @spec preview(Path.t(), Path.t(), duration(), dimensions(), opts()) :: :ok + def preview(video, gif, duration, dimensions, opts \\ []) do + target_framerate = Keyword.get(opts, :target_framerate, 2) + + num_images = + Keyword.get_lazy(opts, :num_images, fn -> + cond do + duration >= 90 -> 20 + duration >= 30 -> 10 + duration >= 1 -> 5 + true -> 2 + end + end) + + {_output, 0} = + System.cmd( + "ffmpeg", + commands(video, gif, clamp(duration), dimensions, num_images, target_framerate) + ) + + :ok + end + + @spec commands(Path.t(), Path.t(), duration(), dimensions(), num_images(), target_framerate()) :: + [String.t()] + defp commands(video, gif, duration, {target_width, target_height}, num_images, target_framerate) do + # Compute range [0, num_images) + image_range = 0..(num_images - 1) + + # Generate input list in the following form: + # -ss 0.0 -i input.webm + input_arguments = + Enum.flat_map(image_range, &["-ss", "#{&1 * duration / num_images}", "-i", video]) + + # Generate graph in the following form: + # [0:v] trim=end_frame=1 [t0]; [1:v] trim=end_frame=1 [t1] ... + trim_filters = + Enum.map_join(image_range, ";", &"[#{&1}:v] trim=end_frame=1 [t#{&1}]") + + # Generate graph in the following form: + # [t0][t1]... concat=n=10 [concat] + concat_input_pads = + Enum.map_join(image_range, "", &"[t#{&1}]") + + concat_filter = + "#{concat_input_pads} concat=n=#{num_images}, settb=1/#{target_framerate}, setpts=N [concat]" + + scale_filter = + "[concat] scale=width=#{target_width}:height=#{target_height}:" <> + "force_original_aspect_ratio=decrease [scale]" + + split_filter = "[scale] split [s0][s1]" + + palettegen_filter = + "[s0] palettegen=stats_mode=single:max_colors=255:reserve_transparent=1 [palettegen]" + + paletteuse_filter = + "[s1][palettegen] paletteuse=dither=bayer:bayer_scale=5:new=1:alpha_threshold=255" + + filter_graph = + [ + trim_filters, + concat_filter, + scale_filter, + split_filter, + palettegen_filter, + paletteuse_filter + ] + |> Enum.join(";") + + # Delay in centiseconds - otherwise it will be computed incorrectly + final_delay = 100.0 / target_framerate + + ["-loglevel", "0", "-y"] + |> Kernel.++(input_arguments) + |> Kernel.++(["-lavfi", filter_graph]) + |> Kernel.++(["-f", "gif", "-final_delay", "#{final_delay}", gif]) + end + + defp clamp(duration) when duration <= 0, do: 1.0 + defp clamp(duration), do: duration +end diff --git a/lib/philomena_media/intensities.ex b/lib/philomena_media/intensities.ex index 5abd71e0..ea095295 100644 --- a/lib/philomena_media/intensities.ex +++ b/lib/philomena_media/intensities.ex @@ -36,7 +36,7 @@ defmodule PhilomenaMedia.Intensities do > #### Info {: .info} > - > Clients should prefer to use `m:PhilomenaMedia.Processors.intensities/2`, as it handles + > Clients should prefer to use `PhilomenaMedia.Processors.intensities/2`, as it handles > media files of any type supported by this library, not just PNG or JPEG. ## Examples diff --git a/lib/philomena_media/processors.ex b/lib/philomena_media/processors.ex index 23c49dcf..22ce613b 100644 --- a/lib/philomena_media/processors.ex +++ b/lib/philomena_media/processors.ex @@ -62,29 +62,31 @@ defmodule PhilomenaMedia.Processors do alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm} alias PhilomenaMedia.Mime - # The name of a version, like :large + @typedoc "The name of a version, like `:large`." @type version_name :: atom() @type dimensions :: {integer(), integer()} @type version_list :: [{version_name(), dimensions()}] - # The file name of a processed version, like "large.png" + @typedoc "The file name of a processed version, like `large.png`." @type version_filename :: String.t() - # A single file to be copied to satisfy a request for a version name + @typedoc "A single file to be copied to satisfy a request for a version name." @type copy_request :: {:copy, Path.t(), version_filename()} - # A list of thumbnail versions to copy into place + @typedoc "A list of thumbnail versions to copy into place." @type thumbnails :: {:thumbnails, [copy_request()]} - # Replace the original file to strip metadata or losslessly optimize + @typedoc "Replace the original file to strip metadata or losslessly optimize." @type replace_original :: {:replace_original, Path.t()} - # Apply the computed corner intensities + @typedoc "Apply the computed corner intensities." @type intensities :: {:intensities, Intensities.t()} - # An edit script, representing the changes to apply to the storage backend - # after successful processing + @typedoc """ + An edit script, representing the changes to apply to the storage backend + after successful processing. + """ @type edit_script :: [thumbnails() | replace_original() | intensities()] @doc """ diff --git a/lib/philomena_media/processors/processor.ex b/lib/philomena_media/processors/processor.ex index 2c3acc0b..8b9f568f 100644 --- a/lib/philomena_media/processors/processor.ex +++ b/lib/philomena_media/processors/processor.ex @@ -5,17 +5,25 @@ defmodule PhilomenaMedia.Processors.Processor do alias PhilomenaMedia.Processors alias PhilomenaMedia.Intensities - # Generate a list of version filenames for the given version list. + @doc """ + Generate a list of version filenames for the given version list. + """ @callback versions(Processors.version_list()) :: [Processors.version_filename()] - # Process the media at the given path against the given version list, and return an - # edit script with the resulting files + @doc """ + Process the media at the given path against the given version list, and return an + edit script with the resulting files. + """ @callback process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script() - # Perform post-processing optimization tasks on the file, to reduce its size - # and strip non-essential metadata + @doc """ + Perform post-processing optimization tasks on the file, to reduce its size + and strip non-essential metadata. + """ @callback post_process(Result.t(), Path.t()) :: Processors.edit_script() - # Generate corner intensities for the given path + @doc """ + Generate corner intensities for the given path. + """ @callback intensities(Result.t(), Path.t()) :: Intensities.t() end diff --git a/lib/philomena_media/processors/webm.ex b/lib/philomena_media/processors/webm.ex index c86e1969..ad446645 100644 --- a/lib/philomena_media/processors/webm.ex +++ b/lib/philomena_media/processors/webm.ex @@ -3,6 +3,7 @@ defmodule PhilomenaMedia.Processors.Webm do alias PhilomenaMedia.Intensities alias PhilomenaMedia.Analyzers.Result + alias PhilomenaMedia.GifPreview alias PhilomenaMedia.Processors.Processor alias PhilomenaMedia.Processors import Bitwise @@ -28,12 +29,11 @@ defmodule PhilomenaMedia.Processors.Webm do duration = analysis.duration stripped = strip(file) preview = preview(duration, stripped) - palette = gif_palette(stripped, duration) mp4 = scale_mp4_only(stripped, dimensions, dimensions) {:ok, intensities} = Intensities.file(preview) - scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1)) + scaled = Enum.flat_map(versions, &scale(stripped, duration, dimensions, &1)) mp4 = [{:copy, mp4, "full.mp4"}] [ @@ -82,12 +82,12 @@ defmodule PhilomenaMedia.Processors.Webm do stripped end - defp scale(file, palette, duration, dimensions, {thumb_name, target_dimensions}) do + defp scale(file, duration, dimensions, {thumb_name, target_dimensions}) do {webm, mp4} = scale_videos(file, dimensions, target_dimensions) cond do thumb_name in [:thumb, :thumb_small, :thumb_tiny] -> - gif = scale_gif(file, palette, duration, target_dimensions) + gif = scale_gif(file, duration, target_dimensions) [ {:copy, webm, "#{thumb_name}.webm"}, @@ -199,53 +199,14 @@ defmodule PhilomenaMedia.Processors.Webm do mp4 end - defp scale_gif(file, palette, duration, {width, height}) do + defp scale_gif(file, duration, dimensions) do gif = Briefly.create!(extname: ".gif") - scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease" - palette_filter = "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" - rate_filter = rate_filter(duration) - filter_graph = "[0:v]#{scale_filter},#{rate_filter}[x];[x][1:v]#{palette_filter}" - {_output, 0} = - System.cmd("ffmpeg", [ - "-loglevel", - "0", - "-y", - "-i", - file, - "-i", - palette, - "-lavfi", - filter_graph, - "-r", - "2", - gif - ]) + GifPreview.preview(file, gif, duration, dimensions) gif end - defp gif_palette(file, duration) do - palette = Briefly.create!(extname: ".png") - palette_filter = "palettegen=stats_mode=diff" - rate_filter = rate_filter(duration) - filter_graph = "#{rate_filter},#{palette_filter}" - - {_output, 0} = - System.cmd("ffmpeg", [ - "-loglevel", - "0", - "-y", - "-i", - file, - "-vf", - filter_graph, - palette - ]) - - palette - end - # x264 requires image dimensions to be a multiple of 2 # -2 = ~1 def box_dimensions({width, height}, {target_width, target_height}) do @@ -255,8 +216,4 @@ defmodule PhilomenaMedia.Processors.Webm do {new_width, new_height} end - - # Avoid division by zero - def rate_filter(duration) when duration > 0.5, do: "fps=1/#{duration / 10},settb=1/2,setpts=N" - def rate_filter(_duration), do: "setpts=N/TB/2" end diff --git a/lib/philomena_media/req.ex b/lib/philomena_media/req.ex new file mode 100644 index 00000000..ff92d949 --- /dev/null +++ b/lib/philomena_media/req.ex @@ -0,0 +1,31 @@ +defmodule PhilomenaMedia.Req do + @behaviour ExAws.Request.HttpClient + + @moduledoc """ + Configuration for `m:Req`. + + Options can be set for `m:Req` with the following config: + + config :philomena, :req_opts, + receive_timeout: 30_000 + + The default config handles setting the above. + """ + + @default_opts [receive_timeout: 30_000] + + @impl true + def request(method, url, body \\ "", headers \\ [], http_opts \\ []) do + [method: method, url: url, body: body, headers: headers, decode_body: false] + |> Keyword.merge(Application.get_env(:philomena, :req_opts, @default_opts)) + |> Keyword.merge(http_opts) + |> Req.request() + |> case do + {:ok, %{status: status, headers: headers, body: body}} -> + {:ok, %{status_code: status, headers: headers, body: body}} + + {:error, reason} -> + {:error, %{reason: reason}} + end + end +end diff --git a/lib/philomena_proxy/http.ex b/lib/philomena_proxy/http.ex index 9a5af4ec..5558f697 100644 --- a/lib/philomena_proxy/http.ex +++ b/lib/philomena_proxy/http.ex @@ -17,9 +17,13 @@ defmodule PhilomenaProxy.Http do @type url :: String.t() @type header_list :: [{String.t(), String.t()}] - @type body :: binary() + @type body :: iodata() + @type result :: {:ok, Req.Response.t()} | {:error, Exception.t()} - @type client_options :: keyword() + @user_agent "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0" + @max_body 125_000_000 + + @max_body_key :resp_body_size @doc ~S""" Perform a HTTP GET request. @@ -27,15 +31,15 @@ defmodule PhilomenaProxy.Http do ## Example iex> PhilomenaProxy.Http.get("http://example.com", [{"authorization", "Bearer #{token}"}]) - {:ok, %Tesla.Env{...}} + {:ok, %{status: 200, body: ...}} iex> PhilomenaProxy.Http.get("http://nonexistent.example.com") - {:error, %Mint.TransportError{reason: :nxdomain}} + {:error, %Req.TransportError{reason: :nxdomain}} """ - @spec get(url(), header_list(), client_options()) :: Tesla.Env.result() - def get(url, headers \\ [], options \\ []) do - Tesla.get(client(headers), url, opts: [adapter: adapter_opts(options)]) + @spec get(url(), header_list()) :: result() + def get(url, headers \\ []) do + request(:get, url, [], headers) end @doc ~S""" @@ -44,15 +48,15 @@ defmodule PhilomenaProxy.Http do ## Example iex> PhilomenaProxy.Http.head("http://example.com", [{"authorization", "Bearer #{token}"}]) - {:ok, %Tesla.Env{...}} + {:ok, %{status: 200, body: ...}} iex> PhilomenaProxy.Http.head("http://nonexistent.example.com") - {:error, %Mint.TransportError{reason: :nxdomain}} + {:error, %Req.TransportError{reason: :nxdomain}} """ - @spec head(url(), header_list(), client_options()) :: Tesla.Env.result() - def head(url, headers \\ [], options \\ []) do - Tesla.head(client(headers), url, opts: [adapter: adapter_opts(options)]) + @spec head(url(), header_list()) :: result() + def head(url, headers \\ []) do + request(:head, url, [], headers) end @doc ~S""" @@ -61,27 +65,67 @@ defmodule PhilomenaProxy.Http do ## Example iex> PhilomenaProxy.Http.post("http://example.com", "", [{"authorization", "Bearer #{token}"}]) - {:ok, %Tesla.Env{...}} + {:ok, %{status: 200, body: ...}} iex> PhilomenaProxy.Http.post("http://nonexistent.example.com", "") - {:error, %Mint.TransportError{reason: :nxdomain}} + {:error, %Req.TransportError{reason: :nxdomain}} """ - @spec post(url(), body(), header_list(), client_options()) :: Tesla.Env.result() - def post(url, body, headers \\ [], options \\ []) do - Tesla.post(client(headers), url, body, opts: [adapter: adapter_opts(options)]) + @spec post(url(), body(), header_list()) :: result() + def post(url, body, headers \\ []) do + request(:post, url, body, headers) end - defp adapter_opts(opts) do - opts = Keyword.merge(opts, max_body: 125_000_000, inet6: true) + @spec request(atom(), String.t(), iodata(), header_list()) :: result() + defp request(method, url, body, headers) do + Req.new( + method: method, + url: url, + body: body, + headers: [{:user_agent, @user_agent} | headers], + max_redirects: 1, + connect_options: connect_options(url), + inet6: true, + into: &stream_response_callback/2, + decode_body: false + ) + |> Req.Request.put_private(@max_body_key, 0) + |> Req.request() + end - case Application.get_env(:philomena, :proxy_host) do - nil -> - opts + defp connect_options(url) do + transport_opts = + case URI.parse(url) do + %{scheme: "https"} -> + # SSL defaults validate SHA-1 on root certificates but this is unnecessary because many + # many roots are still signed with SHA-1 and it isn't relevant for security. Relax to + # allow validation of SHA-1, even though this creates a less secure client. + # https://github.com/erlang/otp/issues/8601 + [ + transport_opts: [ + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ], + signature_algs_cert: :ssl.signature_algs(:default, :"tlsv1.3") ++ [sha: :rsa] + ] + ] - url -> - Keyword.merge(opts, proxy: proxy_opts(URI.parse(url))) - end + _ -> + # Do not pass any options for non-HTTPS schemes. Finch will raise badarg if the above + # options are passed. + [] + end + + proxy_opts = + case Application.get_env(:philomena, :proxy_host) do + nil -> + [] + + url -> + [proxy: proxy_opts(URI.parse(url))] + end + + transport_opts ++ proxy_opts end defp proxy_opts(%{host: host, port: port, scheme: "https"}), @@ -90,18 +134,14 @@ defmodule PhilomenaProxy.Http do defp proxy_opts(%{host: host, port: port, scheme: "http"}), do: {:http, host, port, [transport_opts: [inet6: true]]} - defp client(headers) do - Tesla.client( - [ - {Tesla.Middleware.FollowRedirects, max_redirects: 1}, - {Tesla.Middleware.Headers, - [ - {"User-Agent", - "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0"} - | headers - ]} - ], - Tesla.Adapter.Mint - ) + defp stream_response_callback({:data, data}, {req, resp}) do + req = update_in(req.private[@max_body_key], &(&1 + byte_size(data))) + resp = update_in(resp.body, &<<&1::binary, data::binary>>) + + if req.private.resp_body_size < @max_body do + {:cont, {req, resp}} + else + {:halt, {req, RuntimeError.exception("body too big")}} + end end end diff --git a/lib/philomena_proxy/scrapers.ex b/lib/philomena_proxy/scrapers.ex index 9a166887..a96f0817 100644 --- a/lib/philomena_proxy/scrapers.ex +++ b/lib/philomena_proxy/scrapers.ex @@ -3,16 +3,16 @@ defmodule PhilomenaProxy.Scrapers do Scrape utilities to facilitate uploading media from other websites. """ - # The URL to fetch, as a string. + @typedoc "The URL to fetch, as a string." @type url :: String.t() - # An individual image in a list associated with a scrape result. + @typedoc "An individual image in a list associated with a scrape result." @type image_result :: %{ url: url(), camo_url: url() } - # Result of a successful scrape. + @typedoc "Result of a successful scrape." @type scrape_result :: %{ source_url: url(), description: String.t() | nil, @@ -56,16 +56,17 @@ defmodule PhilomenaProxy.Scrapers do def scrape!(url) do uri = URI.parse(url) - @scrapers - |> Enum.find(& &1.can_handle?(uri, url)) - |> wrap() - |> Enum.map(& &1.scrape(uri, url)) - |> unwrap() + cond do + is_nil(uri.host) -> + # Scraping without a hostname doesn't make sense because the proxy cannot fetch it, and + # some scrapers may test properties of the hostname. + nil + + true -> + # Find the first scraper which can handle the URL and process, or return nil + Enum.find_value(@scrapers, nil, fn scraper -> + scraper.can_handle?(uri, url) && scraper.scrape(uri, url) + end) + end end - - defp wrap(nil), do: [] - defp wrap(res), do: [res] - - defp unwrap([result]), do: result - defp unwrap(_result), do: nil end diff --git a/lib/philomena_proxy/scrapers/deviantart.ex b/lib/philomena_proxy/scrapers/deviantart.ex index 10985133..cf8009d0 100644 --- a/lib/philomena_proxy/scrapers/deviantart.ex +++ b/lib/philomena_proxy/scrapers/deviantart.ex @@ -9,7 +9,6 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do @image_regex ~r|data-rh="true" rel="preload" href="([^"]*)" as="image"| @source_regex ~r|rel="canonical" href="([^"]*)"| @artist_regex ~r|https://www.deviantart.com/([^/]*)/art| - @serial_regex ~r|https://www.deviantart.com/(?:.*?)-(\d+)\z| @cdnint_regex ~r|(https://images-wixmp-[0-9a-f]+.wixmp.com)(?:/intermediary)?/f/([^/]*)/([^/?]*)| @png_regex ~r|(https://[0-9a-z\-\.]+(?:/intermediary)?/f/[0-9a-f\-]+/[0-9a-z\-]+\.png/v1/fill/[0-9a-z_,]+/[0-9a-z_\-]+)(\.png)(.*)| @jpg_regex ~r|(https://[0-9a-z\-\.]+(?:/intermediary)?/f/[0-9a-f\-]+/[0-9a-z\-]+\.jpg/v1/fill/w_[0-9]+,h_[0-9]+,q_)([0-9]+)(,[a-z]+\/[a-z0-6_\-]+\.jpe?g.*)| @@ -31,14 +30,13 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do @spec scrape(URI.t(), Scrapers.url()) :: Scrapers.scrape_result() def scrape(_uri, url) do url - |> follow_redirect(2) + |> PhilomenaProxy.Http.get() |> extract_data!() |> try_intermediary_hires!() |> try_new_hires!() - |> try_old_hires!() end - defp extract_data!({:ok, %Tesla.Env{body: body, status: 200}}) do + defp extract_data!({:ok, %{body: body, status: 200}}) do [image] = Regex.run(@image_regex, body, capture: :all_but_first) [source] = Regex.run(@source_regex, body, capture: :all_but_first) [artist] = Regex.run(@artist_regex, source, capture: :all_but_first) @@ -60,7 +58,7 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do with [domain, object_uuid, object_name] <- Regex.run(@cdnint_regex, image.url, capture: :all_but_first), built_url <- "#{domain}/intermediary/f/#{object_uuid}/#{object_name}", - {:ok, %Tesla.Env{status: 200}} <- PhilomenaProxy.Http.head(built_url) do + {:ok, %{status: 200}} <- PhilomenaProxy.Http.head(built_url) do # This is the high resolution URL. %{ data @@ -107,54 +105,4 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do data end end - - defp try_old_hires!(%{source_url: source, images: [image]} = data) do - [serial] = Regex.run(@serial_regex, source, capture: :all_but_first) - - base36 = - serial - |> String.to_integer() - |> Integer.to_string(36) - |> String.downcase() - - built_url = "http://orig01.deviantart.net/x_by_x-d#{base36}.png" - - case PhilomenaProxy.Http.get(built_url) do - {:ok, %Tesla.Env{status: 301, headers: headers}} -> - # Location header provides URL of high res image. - {_location, link} = Enum.find(headers, fn {header, _val} -> header == "location" end) - - %{ - data - | images: [ - %{ - url: link, - camo_url: image.camo_url - } - ] - } - - _ -> - # Nothing to be found here, move along... - data - end - end - - # Workaround for benoitc/hackney#273 - defp follow_redirect(_url, 0), do: nil - - defp follow_redirect(url, max_times) do - case PhilomenaProxy.Http.get(url) do - {:ok, %Tesla.Env{headers: headers, status: code}} when code in [301, 302] -> - location = Enum.find_value(headers, &location_header/1) - follow_redirect(location, max_times - 1) - - response -> - response - end - end - - defp location_header({"Location", location}), do: location - defp location_header({"location", location}), do: location - defp location_header(_), do: nil end diff --git a/lib/philomena_proxy/scrapers/pillowfort.ex b/lib/philomena_proxy/scrapers/pillowfort.ex index 6e083c9c..91c5a90d 100755 --- a/lib/philomena_proxy/scrapers/pillowfort.ex +++ b/lib/philomena_proxy/scrapers/pillowfort.ex @@ -24,7 +24,7 @@ defmodule PhilomenaProxy.Scrapers.Pillowfort do |> process_response!(url) end - defp json!({:ok, %Tesla.Env{body: body, status: 200}}), + defp json!({:ok, %{body: body, status: 200}}), do: Jason.decode!(body) defp process_response!(post_json, url) do diff --git a/lib/philomena_proxy/scrapers/raw.ex b/lib/philomena_proxy/scrapers/raw.ex index ed31d10b..a6985444 100644 --- a/lib/philomena_proxy/scrapers/raw.ex +++ b/lib/philomena_proxy/scrapers/raw.ex @@ -10,14 +10,10 @@ defmodule PhilomenaProxy.Scrapers.Raw do @spec can_handle?(URI.t(), String.t()) :: boolean() def can_handle?(_uri, url) do - PhilomenaProxy.Http.head(url) - |> case do - {:ok, %Tesla.Env{status: 200, headers: headers}} -> - headers - |> Enum.any?(fn {k, v} -> - String.downcase(k) == "content-type" and String.downcase(v) in @mime_types - end) - + with {:ok, %{status: 200, headers: headers}} <- PhilomenaProxy.Http.head(url), + [type | _] <- headers["content-type"] do + String.downcase(type) in @mime_types + else _ -> false end diff --git a/lib/philomena_proxy/scrapers/tumblr.ex b/lib/philomena_proxy/scrapers/tumblr.ex index fe648e66..4863fb39 100644 --- a/lib/philomena_proxy/scrapers/tumblr.ex +++ b/lib/philomena_proxy/scrapers/tumblr.ex @@ -37,7 +37,7 @@ defmodule PhilomenaProxy.Scrapers.Tumblr do |> process_response!() end - defp json!({:ok, %Tesla.Env{body: body, status: 200}}), + defp json!({:ok, %{body: body, status: 200}}), do: Jason.decode!(body) defp process_response!(%{"response" => %{"posts" => [post | _rest]}}), @@ -76,7 +76,7 @@ defmodule PhilomenaProxy.Scrapers.Tumblr do end defp url_ok?(url) do - match?({:ok, %Tesla.Env{status: 200}}, PhilomenaProxy.Http.head(url)) + match?({:ok, %{status: 200}}, PhilomenaProxy.Http.head(url)) end defp add_meta(post, images) do diff --git a/lib/philomena_proxy/scrapers/twitter.ex b/lib/philomena_proxy/scrapers/twitter.ex index def1a374..a3b167f9 100644 --- a/lib/philomena_proxy/scrapers/twitter.ex +++ b/lib/philomena_proxy/scrapers/twitter.ex @@ -18,7 +18,7 @@ defmodule PhilomenaProxy.Scrapers.Twitter do [user, status_id] = Regex.run(@url_regex, url, capture: :all_but_first) api_url = "https://api.fxtwitter.com/#{user}/status/#{status_id}" - {:ok, %Tesla.Env{status: 200, body: body}} = PhilomenaProxy.Http.get(api_url) + {:ok, %{status: 200, body: body}} = PhilomenaProxy.Http.get(api_url) json = Jason.decode!(body) tweet = json["tweet"] diff --git a/lib/philomena_query/batch.ex b/lib/philomena_query/batch.ex index ce78cb6a..918a3b5e 100644 --- a/lib/philomena_query/batch.ex +++ b/lib/philomena_query/batch.ex @@ -13,13 +13,31 @@ defmodule PhilomenaQuery.Batch do alias Philomena.Repo import Ecto.Query + @typedoc """ + Represents an object which may be operated on via `m:Ecto.Query`. + + This could be a schema object (e.g. `m:Philomena.Images.Image`) or a fully formed query + `from i in Image, where: i.hidden_from_users == false`. + """ @type queryable :: any() @type batch_size :: {:batch_size, integer()} @type id_field :: {:id_field, atom()} @type batch_options :: [batch_size() | id_field()] + @typedoc """ + The callback for `record_batches/3`. + + Takes a list of schema structs which were returned in the batch. Return value is ignored. + """ @type record_batch_callback :: ([struct()] -> any()) + + @typedoc """ + The callback for `query_batches/3`. + + Takes an `m:Ecto.Query` that can be processed with `m:Philomena.Repo` query commands, such + as `Philomena.Repo.update_all/3` or `Philomena.Repo.delete_all/2`. Return value is ignored. + """ @type query_batch_callback :: ([Ecto.Query.t()] -> any()) @doc """ diff --git a/lib/philomena_query/ip_mask.ex b/lib/philomena_query/ip_mask.ex new file mode 100644 index 00000000..bbcd160b --- /dev/null +++ b/lib/philomena_query/ip_mask.ex @@ -0,0 +1,109 @@ +defmodule PhilomenaQuery.IpMask do + @moduledoc """ + Postgres IP masks. + """ + + @doc """ + Parse a netmask from a string parameter, producing an `m:Postgrex.INET` type suitable for use in + a containment (<<=, <<, >>, >>=) query. Ignores invalid strings and passes the IP through on + error. [Postgres documentation](https://www.postgresql.org/docs/current/functions-net.html) + has more information on `inet` operations. + + > #### Info {: .info} + > + > Netmasks lower than /8 are clamped to a minimum of /8. Such low masks are unlikely to be + > useful and this avoids producing very expensive masks to evaluate. + + ## Examples + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "12"}) + %Postgrex.INET{address: {192, 160, 0, 0}, netmask: 12} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "4"}) + %Postgrex.INET{address: {192, 0, 0, 0}, netmask: 8} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "64"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "e"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{ + ...> address: {0x2001, 0xab0, 0x33a8, 0xd6e2, 0x10e9, 0xac1b, 0x9b0f, 0x67bc}, + ...> netmask: 128 + ...> }, %{"mask" => "64"}) + %Postgrex.INET{address: {8193, 2736, 13224, 55010, 0, 0, 0, 0}, netmask: 64} + + """ + @spec parse_mask(Postgrex.INET.t(), map()) :: Postgrex.INET.t() + def parse_mask(ip, params) + + def parse_mask(ip, %{"mask" => mask}) when is_binary(mask) do + case Integer.parse(mask) do + {mask, _rest} -> + mask = clamp_mask(ip.address, mask) + address = apply_mask(ip.address, mask) + + %Postgrex.INET{address: address, netmask: mask} + + _ -> + ip + end + end + + def parse_mask(ip, _params), do: ip + + defp clamp(n, min, _max) when n < min, do: min + defp clamp(n, _min, max) when n > max, do: max + defp clamp(n, _min, _max), do: n + + defp clamp_mask(ip, mask) do + # Clamp mask length: + # - low end 8 (too taxing to evaluate) + # - high end address_bits (limit of address) + case tuple_size(ip) do + 4 -> + clamp(mask, 8, 32) + + 8 -> + clamp(mask, 8, 128) + end + end + + defp unit_length(ip) when tuple_size(ip) == 4, do: 8 + defp unit_length(ip) when tuple_size(ip) == 8, do: 16 + + defp apply_mask(ip, mask) when is_tuple(ip) do + # Determine whether elements are octets or hexadectets + length = unit_length(ip) + + # 1. Convert tuple to list of octets/hexadectets + # 2. Convert list to bitstring + # 3. Perform truncation operation on bitstring + # 4. Convert bitstring back to list of octets/hexadectets + # 5. Convert list to tuple + + ip + |> Tuple.to_list() + |> list_to_bits(length) + |> apply_mask(mask) + |> bits_to_list(length) + |> List.to_tuple() + end + + defp apply_mask(ip, mask) when is_binary(ip) do + # Truncate bit size of ip to mask length and zero-fill the remainder + <> + end + + defp list_to_bits(list, unit_length) do + for u <- list, into: <<>>, do: <> + end + + defp bits_to_list(bits, unit_length) do + for <>, do: u + end +end diff --git a/lib/philomena_query/parse/parser.ex b/lib/philomena_query/parse/parser.ex index ba4b0597..a9d40222 100644 --- a/lib/philomena_query/parse/parser.ex +++ b/lib/philomena_query/parse/parser.ex @@ -41,12 +41,34 @@ defmodule PhilomenaQuery.Parse.Parser do TermRangeParser } + @typedoc """ + User-supplied context argument. + + Provided to `parse/3` and passed to the transform callback. + """ @type context :: any() + + @typedoc "Query in the search engine JSON query language." @type query :: map() + @typedoc "Whether the default field is `:term` (not analyzed) or `:ngram` (analyzed)." @type default_field_type :: :term | :ngram + @typedoc """ + Return value of the transform callback. + + On `{:ok, query}`, the query is incorporated into the parse tree at the current location. + On `{:error, error}`, parsing immediately stops and the error is returned from the parser. + """ @type transform_result :: {:ok, query()} | {:error, String.t()} + + @typedoc """ + Type of the transform callback. + + The transform callback receives the context argument passed to `parse/3` and the remainder of + the term. For instance `my:example` would match a transform rule with the key `"my"`, and + the remainder passed to the callback would be `"example"`. + """ @type transform :: (context, String.t() -> transform_result()) @type t :: %__MODULE__{ @@ -112,11 +134,11 @@ defmodule PhilomenaQuery.Parse.Parser do aliases: %{"hidden" => "hidden_from_users"} ] - Parser.parser(options) + Parser.new(options) """ - @spec parser(keyword()) :: t() - def parser(options) do + @spec new(keyword()) :: t() + def new(options) do parser = struct(Parser, options) fields = diff --git a/lib/philomena_query/search.ex b/lib/philomena_query/search.ex index b4960657..140adf67 100644 --- a/lib/philomena_query/search.ex +++ b/lib/philomena_query/search.ex @@ -18,10 +18,36 @@ defmodule PhilomenaQuery.Search do # todo: fetch through compile_env? @policy Philomena.SearchPolicy + @typedoc """ + Any schema module which has an associated search index. See the policy module + for more information. + """ @type schema_module :: @policy.schema_module() + + @typedoc """ + Represents an object which may be operated on via `m:Ecto.Query`. + + This could be a schema object (e.g. `m:Philomena.Images.Image`) or a fully formed query + `from i in Image, where: i.hidden_from_users == false`. + """ @type queryable :: any() + + @typedoc """ + A query body, as deliverable to any index's `_search` endpoint. + + See the query DSL documentation for additional information: + https://opensearch.org/docs/latest/query-dsl/ + """ @type query_body :: map() + @typedoc """ + Given a term at the given path, replace the old term with the new term. + + `path` is a list of names to be followed to find the old term. For example, + a document containing `{"condiments": "dijon"}` would permit `["condiments"]` + as the path, and a document containing `{"namespaced_tags": {"name": ["old"]}}` + would permit `["namespaced_tags", "name"]` as the path. + """ @type replacement :: %{ path: [String.t()], old: term(), diff --git a/lib/philomena_query/search_index.ex b/lib/philomena_query/search_index.ex index 3a4fe9da..119d2613 100644 --- a/lib/philomena_query/search_index.ex +++ b/lib/philomena_query/search_index.ex @@ -1,11 +1,34 @@ defmodule PhilomenaQuery.SearchIndex do - # Returns the index name for the index. - # This is usually a collection name like "images". + @moduledoc """ + Behaviour module for schemas with search indexing. + """ + + @doc """ + Returns the index name for the index. + + This is usually a collection name like "images". + + See https://opensearch.org/docs/latest/api-reference/index-apis/create-index/ for + reference on index naming restrictions. + """ @callback index_name() :: String.t() - # Returns the mapping and settings for the index. + @doc """ + Returns the mapping and settings for the index. + + See https://opensearch.org/docs/latest/api-reference/index-apis/put-mapping/ for + reference on the mapping syntax, and the following pages for which types may be + used in mappings: + - https://opensearch.org/docs/latest/field-types/ + - https://opensearch.org/docs/latest/analyzers/index-analyzers/ + """ @callback mapping() :: map() - # Returns the JSON representation of the given struct for indexing in OpenSearch. + @doc """ + Returns the JSON representation of the given struct for indexing in OpenSearch. + + See https://opensearch.org/docs/latest/api-reference/document-apis/index-document/ for + reference on how this value is used. + """ @callback as_json(struct()) :: map() end diff --git a/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex b/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex index ccc4f56a..d3c17c12 100644 --- a/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex +++ b/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex @@ -7,7 +7,12 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do import Ecto.Query plug :verify_authorized - plug :load_resource, model: FingerprintBan, only: [:edit, :update, :delete] + + plug :load_resource, + model: FingerprintBan, + as: :fingerprint_ban, + only: [:edit, :update, :delete] + plug :check_can_delete when action in [:delete] def index(conn, %{"q" => q}) when is_binary(q) do @@ -56,12 +61,12 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do end def edit(conn, _params) do - changeset = Bans.change_fingerprint(conn.assigns.fingerprint) + changeset = Bans.change_fingerprint(conn.assigns.fingerprint_ban) render(conn, "edit.html", title: "Editing Fingerprint Ban", changeset: changeset) end def update(conn, %{"fingerprint" => fingerprint_ban_params}) do - case Bans.update_fingerprint(conn.assigns.fingerprint, fingerprint_ban_params) do + case Bans.update_fingerprint(conn.assigns.fingerprint_ban, fingerprint_ban_params) do {:ok, fingerprint_ban} -> conn |> put_flash(:info, "Fingerprint ban successfully updated.") @@ -74,7 +79,7 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do end def delete(conn, _params) do - {:ok, fingerprint_ban} = Bans.delete_fingerprint(conn.assigns.fingerprint) + {:ok, fingerprint_ban} = Bans.delete_fingerprint(conn.assigns.fingerprint_ban) conn |> put_flash(:info, "Fingerprint ban successfully deleted.") diff --git a/lib/philomena_web/controllers/admin/site_notice_controller.ex b/lib/philomena_web/controllers/admin/site_notice_controller.ex index 7612422e..ff284a75 100644 --- a/lib/philomena_web/controllers/admin/site_notice_controller.ex +++ b/lib/philomena_web/controllers/admin/site_notice_controller.ex @@ -44,7 +44,7 @@ defmodule PhilomenaWeb.Admin.SiteNoticeController do case SiteNotices.update_site_notice(conn.assigns.site_notice, site_notice_params) do {:ok, _site_notice} -> conn - |> put_flash(:info, "Succesfully updated site notice.") + |> put_flash(:info, "Successfully updated site notice.") |> redirect(to: ~p"/admin/site_notices") {:error, changeset} -> @@ -56,7 +56,7 @@ defmodule PhilomenaWeb.Admin.SiteNoticeController do {:ok, _site_notice} = SiteNotices.delete_site_notice(conn.assigns.site_notice) conn - |> put_flash(:info, "Sucessfully deleted site notice.") + |> put_flash(:info, "Successfully deleted site notice.") |> redirect(to: ~p"/admin/site_notices") end diff --git a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex index f5bf868c..d82359e2 100644 --- a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex @@ -1,25 +1,27 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.SourceChanges.SourceChange alias Philomena.Repo import Ecto.Query plug :verify_authorized - def index(conn, %{"ip_profile_id" => ip}) do + def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) source_changes = SourceChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> order_by(desc: :id) |> preload([:user, image: [:user, :sources, tags: :aliases]]) |> Repo.paginate(conn.assigns.scrivener) render(conn, "index.html", title: "Source Changes for IP `#{ip}'", - ip: ip, + ip: range, source_changes: source_changes ) end diff --git a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex index b9779913..bdfebc29 100644 --- a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.TagChanges.TagChange alias Philomena.Repo import Ecto.Query @@ -9,10 +10,11 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) tag_changes = TagChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> added_filter(params) |> preload([:tag, :user, image: [:user, :sources, tags: :aliases]]) |> order_by(desc: :id) @@ -20,7 +22,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do render(conn, "index.html", title: "Tag Changes for IP `#{ip}'", - ip: ip, + ip: range, tag_changes: tag_changes ) end diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index b5f1020d..75f476d4 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -212,7 +212,7 @@ defmodule PhilomenaWeb.ProfileController do end defp individual_stat(mapping, stat_name) do - Enum.map(89..0, &(map_fetch(mapping[&1], stat_name) || 0)) + Enum.map(89..0//-1, &(map_fetch(mapping[&1], stat_name) || 0)) end defp map_fetch(nil, _field_name), do: nil diff --git a/lib/philomena_web/fingerprint.ex b/lib/philomena_web/fingerprint.ex new file mode 100644 index 00000000..99d8fadd --- /dev/null +++ b/lib/philomena_web/fingerprint.ex @@ -0,0 +1,85 @@ +defmodule PhilomenaWeb.Fingerprint do + import Plug.Conn + + @type t :: String.t() + @name "_ses" + + @doc """ + Assign the current fingerprint to the conn. + """ + @spec fetch_fingerprint(Plug.Conn.t(), any()) :: Plug.Conn.t() + def fetch_fingerprint(conn, _opts) do + conn = + conn + |> fetch_session() + |> fetch_cookies() + + # Try to get the fingerprint from the session, then from the cookie. + fingerprint = upgrade(get_session(conn, @name), conn.cookies[@name]) + + # If the fingerprint is valid, persist to session. + case valid_format?(fingerprint) do + true -> + conn + |> put_session(@name, fingerprint) + |> assign(:fingerprint, fingerprint) + + false -> + assign(conn, :fingerprint, nil) + end + end + + defp upgrade(<<"c", _::binary>> = session_value, <<"d", _::binary>> = cookie_value) do + if valid_format?(cookie_value) do + # When both fingerprint values are valid and the session value + # is an old version, use the cookie value. + cookie_value + else + # Use the session value. + session_value + end + end + + defp upgrade(session_value, cookie_value) do + # Prefer the session value, using the cookie value if it is unavailable. + session_value || cookie_value + end + + @doc """ + Determine whether the fingerprint corresponds to a valid format. + + Valid formats start with `c` or `d` (for the version). The `c` format is a legacy format + corresponding to an integer-valued hash from the frontend. The `d` format is the current + format corresponding to a hex-valued hash from the frontend. By design, it is not + possible to infer anything else about these values from the server. + + See assets/js/fp.ts for additional information on the generation of the `d` format. + + ## Examples + + iex> valid_format?("b2502085657") + false + + iex> valid_format?("c637334158") + true + + iex> valid_format?("d63c4581f8cf58d") + true + + iex> valid_format?("5162549b16e8448") + false + + """ + @spec valid_format?(any()) :: boolean() + def valid_format?(fingerprint) + + def valid_format?(<<"c", rest::binary>>) when byte_size(rest) <= 12 do + match?({_result, ""}, Integer.parse(rest)) + end + + def valid_format?(<<"d", rest::binary>>) when byte_size(rest) == 14 do + match?({:ok, _result}, Base.decode16(rest, case: :lower)) + end + + def valid_format?(_fingerprint), do: false +end diff --git a/lib/philomena_web/plugs/compromised_password_check_plug.ex b/lib/philomena_web/plugs/compromised_password_check_plug.ex index bceea818..1af7a24b 100644 --- a/lib/philomena_web/plugs/compromised_password_check_plug.ex +++ b/lib/philomena_web/plugs/compromised_password_check_plug.ex @@ -36,7 +36,7 @@ defmodule PhilomenaWeb.CompromisedPasswordCheckPlug do |> Base.encode16() case PhilomenaProxy.Http.get(make_api_url(prefix)) do - {:ok, %Tesla.Env{body: body, status: 200}} -> String.contains?(body, rest) + {:ok, %{body: body, status: 200}} -> String.contains?(body, rest) _ -> false end end diff --git a/lib/philomena_web/plugs/current_ban_plug.ex b/lib/philomena_web/plugs/current_ban_plug.ex index 0924b858..273a7889 100644 --- a/lib/philomena_web/plugs/current_ban_plug.ex +++ b/lib/philomena_web/plugs/current_ban_plug.ex @@ -16,9 +16,7 @@ defmodule PhilomenaWeb.CurrentBanPlug do @doc false @spec call(Conn.t(), any()) :: Conn.t() def call(conn, _opts) do - conn = Conn.fetch_cookies(conn) - - fingerprint = conn.cookies["_ses"] + fingerprint = conn.assigns.fingerprint user = conn.assigns.current_user ip = conn.remote_ip diff --git a/lib/philomena_web/plugs/filter_banned_users_plug.ex b/lib/philomena_web/plugs/filter_banned_users_plug.ex index 5b5c440d..866bde43 100644 --- a/lib/philomena_web/plugs/filter_banned_users_plug.ex +++ b/lib/philomena_web/plugs/filter_banned_users_plug.ex @@ -37,9 +37,7 @@ defmodule PhilomenaWeb.FilterBannedUsersPlug do defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn defp maybe_halt_no_fingerprint(conn) do - conn = Conn.fetch_cookies(conn) - - case conn.cookies["_ses"] do + case conn.assigns.fingerprint do nil -> PhilomenaWeb.NotAuthorizedPlug.call(conn) diff --git a/lib/philomena_web/plugs/scraper_plug.ex b/lib/philomena_web/plugs/scraper_plug.ex index 2e4e1769..c8064d69 100644 --- a/lib/philomena_web/plugs/scraper_plug.ex +++ b/lib/philomena_web/plugs/scraper_plug.ex @@ -1,10 +1,12 @@ defmodule PhilomenaWeb.ScraperPlug do @filename_regex ~r/filename="([^"]+)"/ + @spec init(keyword()) :: keyword() def init(opts) do opts end + @spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t() def call(conn, opts) do params_name = Keyword.get(opts, :params_name, "image") params_key = Keyword.get(opts, :params_key, "image") @@ -25,18 +27,13 @@ defmodule PhilomenaWeb.ScraperPlug do # Writing the tempfile doesn't allow traversal # sobelow_skip ["Traversal.FileModule"] - defp maybe_fixup_params( - {:ok, %Tesla.Env{body: body, status: 200, headers: headers}}, - url, - opts, - conn - ) do + defp maybe_fixup_params({:ok, %{status: 200} = resp}, url, opts, conn) do params_name = Keyword.get(opts, :params_name, "image") params_key = Keyword.get(opts, :params_key, "image") - name = extract_filename(url, headers) + name = extract_filename(url, resp.headers) file = Plug.Upload.random_file!(UUID.uuid1()) - File.write!(file, body) + File.write!(file, resp.body) fake_upload = %Plug.Upload{ path: file, @@ -44,22 +41,20 @@ defmodule PhilomenaWeb.ScraperPlug do filename: name } - updated_form = Map.put(conn.params[params_name], params_key, fake_upload) - - updated_params = Map.put(conn.params, params_name, updated_form) - - %Plug.Conn{conn | params: updated_params} + put_in(conn.params[params_name][params_key], fake_upload) end defp maybe_fixup_params(_response, _url, _opts, conn), do: conn - defp extract_filename(url, resp_headers) do - {_, header} = - Enum.find(resp_headers, {nil, "filename=\"#{Path.basename(url)}\""}, fn {key, value} -> - key == "content-disposition" and Regex.match?(@filename_regex, value) - end) - - [name] = Regex.run(@filename_regex, header, capture: :all_but_first) + defp extract_filename(url, headers) do + name = + with [value | _] <- headers["content-disposition"], + [name] <- Regex.run(@filename_regex, value, capture: :all_but_first) do + name + else + _ -> + Path.basename(url) + end String.slice(name, 0, 127) end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 8415b112..7a89f4b1 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Router do use PhilomenaWeb, :router import PhilomenaWeb.UserAuth + import PhilomenaWeb.Fingerprint pipeline :browser do plug :accepts, ["html"] @@ -9,6 +10,7 @@ defmodule PhilomenaWeb.Router do plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_fingerprint plug :fetch_current_user plug PhilomenaWeb.ContentSecurityPolicyPlug plug PhilomenaWeb.CurrentFilterPlug diff --git a/lib/philomena_web/templates/admin/fingerprint_ban/edit.html.slime b/lib/philomena_web/templates/admin/fingerprint_ban/edit.html.slime index eb424776..1f9ff5ff 100644 --- a/lib/philomena_web/templates/admin/fingerprint_ban/edit.html.slime +++ b/lib/philomena_web/templates/admin/fingerprint_ban/edit.html.slime @@ -1,6 +1,6 @@ h1 Editing ban -= render PhilomenaWeb.Admin.FingerprintBanView, "_form.html", changeset: @changeset, action: ~p"/admin/fingerprint_bans/#{@fingerprint}", conn: @conn += render PhilomenaWeb.Admin.FingerprintBanView, "_form.html", changeset: @changeset, action: ~p"/admin/fingerprint_bans/#{@fingerprint_ban}", conn: @conn br = link "Back", to: ~p"/admin/fingerprint_bans" diff --git a/lib/philomena_web/templates/conversation/show.html.slime b/lib/philomena_web/templates/conversation/show.html.slime index e62d6fc4..69a2d86c 100644 --- a/lib/philomena_web/templates/conversation/show.html.slime +++ b/lib/philomena_web/templates/conversation/show.html.slime @@ -59,5 +59,5 @@ h1 = @conversation.title p You've managed to send over 1,000 messages in this conversation! p We'd like to ask you to make a new conversation. Don't worry, this one won't go anywhere if you need to refer back to it. p - => link "Click here", to: ~p"/conversations/new?#{[receipient: other.name]}" + => link "Click here", to: ~p"/conversations/new?#{[recipient: other.name]}" ' to make a new conversation with this user. diff --git a/lib/philomena_web/templates/filter/_form.html.slime b/lib/philomena_web/templates/filter/_form.html.slime index 77965906..e46cda9a 100644 --- a/lib/philomena_web/templates/filter/_form.html.slime +++ b/lib/philomena_web/templates/filter/_form.html.slime @@ -73,7 +73,7 @@ .fieldlabel strong You probably do not want to check this unless you know what you are doing - it cannot be changed later - | . Pulic filters can be shared with other users and used by them; if you make changes to a filter, it will update all users of that filter. + | . Public filters can be shared with other users and used by them; if you make changes to a filter, it will update all users of that filter. - input_value(f, :public) == true -> .fieldlabel diff --git a/lib/philomena_web/templates/image/_source.html.slime b/lib/philomena_web/templates/image/_source.html.slime index 6eecc792..ac4b5f36 100644 --- a/lib/philomena_web/templates/image/_source.html.slime +++ b/lib/philomena_web/templates/image/_source.html.slime @@ -8,7 +8,7 @@ p 'The page(s) you found this image on. Images may have a maximum of - span.js-max-source-count> 10 + span.js-max-source-count> 15 ' source URLs. Leave any sources you don't want to use blank. = inputs_for f, :sources, [as: "image[old_sources]", skip_hidden: true], fn fs -> diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime index 42d9f940..e4a77780 100644 --- a/lib/philomena_web/templates/image/new.html.slime +++ b/lib/philomena_web/templates/image/new.html.slime @@ -40,7 +40,7 @@ h4 About this image p 'The page(s) you found this image on. Images may have a maximum of - span.js-max-source-count> 10 + span.js-max-source-count> 15 ' source URLs. Leave any sources you don't want to use blank. = inputs_for f, :sources, fn fs -> diff --git a/lib/philomena_web/templates/ip_profile/show.html.slime b/lib/philomena_web/templates/ip_profile/show.html.slime index eb3ac25c..da8acd3d 100644 --- a/lib/philomena_web/templates/ip_profile/show.html.slime +++ b/lib/philomena_web/templates/ip_profile/show.html.slime @@ -11,8 +11,17 @@ ul h2 Administration Options ul - li = link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" - li = link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + li + => link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes?mask=64" + li + => link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes?mask=64" + li = link "View reports this IP has made", to: ~p"/admin/reports?#{[rq: "ip:#{@ip}"]}" li = link "View IP ban history", to: ~p"/admin/subnet_bans?#{[ip: to_string(@ip)]}" li = link "Ban this sucker", to: ~p"/admin/subnet_bans/new?#{[specification: to_string(@ip)]}" diff --git a/lib/philomena_web/templates/layout/app.html.slime b/lib/philomena_web/templates/layout/app.html.slime index 105b9b76..f0805313 100644 --- a/lib/philomena_web/templates/layout/app.html.slime +++ b/lib/philomena_web/templates/layout/app.html.slime @@ -11,6 +11,7 @@ html lang="en" =< site_name() - else =<> site_name() + link rel="preconnect" href="https://#{cdn_host()}" link rel="stylesheet" href=~p"/css/application.css" link rel="stylesheet" href=stylesheet_path(@conn, @current_user) = if is_nil(@current_user) do diff --git a/lib/philomena_web/templates/markdown/_input.html.slime b/lib/philomena_web/templates/markdown/_input.html.slime index 50724fc5..50c887d7 100644 --- a/lib/philomena_web/templates/markdown/_input.html.slime +++ b/lib/philomena_web/templates/markdown/_input.html.slime @@ -1,6 +1,6 @@ - form = assigns[:f] -- action_text = assigns[:action_text] || 'Edit' -- action_icon = assigns[:action_icon] || 'edit' +- action_text = assigns[:action_text] || "Edit" +- action_icon = assigns[:action_icon] || "edit" - field_name = assigns[:name] || :body - field_placeholder = assigns[:placeholder] || "Your message" - is_required = assigns[:required] @@ -11,7 +11,7 @@ = action_text a.button href="#" data-click-tab="preview" - i.fa.fa-cog.fa-spin.js-preview-loading.hidden> title=raw('Loading preview…') + i.fa.fa-cog.fa-spin.js-preview-loading.hidden> title=raw("Loading preview…") i.fa.fa-eye.js-preview-idle> | Preview diff --git a/lib/philomena_web/templates/profile/commission/_form.html.slime b/lib/philomena_web/templates/profile/commission/_form.html.slime index ac2e286b..1936edf1 100644 --- a/lib/philomena_web/templates/profile/commission/_form.html.slime +++ b/lib/philomena_web/templates/profile/commission/_form.html.slime @@ -23,7 +23,7 @@ .field => label f, :categories, "Art Categories:" br - = collection_checkboxes f, :categories, categories(), selected: f.data.categories, input_opts: [ class: 'checkbox spacing-right' ], wrapper: &Phoenix.HTML.Tag.content_tag(:span, &1, class: "commission__category") + = collection_checkboxes f, :categories, categories(), selected: f.data.categories, input_opts: [ class: "checkbox spacing-right" ], wrapper: &Phoenix.HTML.Tag.content_tag(:span, &1, class: "commission__category") = error_tag f, :categories .field => label f, :sheet_image_id, "Image ID of your commissions sheet (optional but recommended):" diff --git a/lib/philomena_web/templates/tag/_tag.html.slime b/lib/philomena_web/templates/tag/_tag.html.slime index 0de59b7e..9fa32b92 100644 --- a/lib/philomena_web/templates/tag/_tag.html.slime +++ b/lib/philomena_web/templates/tag/_tag.html.slime @@ -1,5 +1,5 @@ span.tag.dropdown data-tag-category="#{@tag.category}" data-tag-id="#{@tag.id}" data-tag-name="#{@tag.name}" data-tag-slug="#{@tag.slug}" - / The order of tag states and dropdown links is important for tags.js + / The order of tag states and dropdown links is important for tags.ts span span.tag__state.hidden title="Unwatched" | + diff --git a/lib/philomena_web/templates/topic/poll/_form.html.slime b/lib/philomena_web/templates/topic/poll/_form.html.slime index c1ba66cf..f5d960b0 100644 --- a/lib/philomena_web/templates/topic/poll/_form.html.slime +++ b/lib/philomena_web/templates/topic/poll/_form.html.slime @@ -29,10 +29,10 @@ p.fieldlabel = select @f, :vote_method, ["-": "", "Single option": :single, "Multiple options": :multiple], class: "input" = error_tag @f, :vote_method -= inputs_for @f, :options, fn fo -> += inputs_for @f, :options, fn opt -> .field.js-poll-option.field--inline.flex--no-wrap.flex--centered - = text_input fo, :label, class: "input flex__grow js-option-label", placeholder: "Option" - = error_tag fo, :label + = text_input opt, :label, class: "input flex__grow js-option-label", placeholder: "Option" + = error_tag opt, :label label.input--separate-left.flex__fixed.flex--centered a.js-option-remove href="#" diff --git a/lib/philomena_web/user_auth.ex b/lib/philomena_web/user_auth.ex index e469f71d..c7bf2431 100644 --- a/lib/philomena_web/user_auth.ex +++ b/lib/philomena_web/user_auth.ex @@ -211,9 +211,8 @@ defmodule PhilomenaWeb.UserAuth do defp update_usages(conn, user) do now = DateTime.utc_now() |> DateTime.truncate(:second) - conn = fetch_cookies(conn) UserIpUpdater.cast(user.id, conn.remote_ip, now) - UserFingerprintUpdater.cast(user.id, conn.cookies["_ses"], now) + UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now) end end diff --git a/lib/philomena_web/user_fingerprint_updater.ex b/lib/philomena_web/user_fingerprint_updater.ex index 62e14270..41863dcf 100644 --- a/lib/philomena_web/user_fingerprint_updater.ex +++ b/lib/philomena_web/user_fingerprint_updater.ex @@ -3,6 +3,8 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do alias Philomena.Repo import Ecto.Query + alias PhilomenaWeb.Fingerprint + def child_spec([]) do %{ id: PhilomenaWeb.UserFingerprintUpdater, @@ -14,14 +16,13 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do {:ok, spawn_link(&init/0)} end - def cast(user_id, <<"c", _rest::binary>> = fingerprint, updated_at) - when byte_size(fingerprint) <= 12 do - pid = Process.whereis(:fingerprint_updater) - if pid, do: send(pid, {user_id, fingerprint, updated_at}) + def cast(user_id, fingerprint, updated_at) do + if Fingerprint.valid_format?(fingerprint) do + pid = Process.whereis(:fingerprint_updater) + if pid, do: send(pid, {user_id, fingerprint, updated_at}) + end end - def cast(_user_id, _fingerprint, _updated_at), do: nil - defp init do Process.register(self(), :fingerprint_updater) run() diff --git a/lib/philomena_web/views/ip_profile_view.ex b/lib/philomena_web/views/ip_profile_view.ex index 9aef6c29..a9f99f20 100644 --- a/lib/philomena_web/views/ip_profile_view.ex +++ b/lib/philomena_web/views/ip_profile_view.ex @@ -1,3 +1,8 @@ defmodule PhilomenaWeb.IpProfileView do use PhilomenaWeb, :view + + @spec ipv6?(Postgrex.INET.t()) :: boolean() + def ipv6?(ip) do + tuple_size(ip.address) == 8 + end end diff --git a/mix.exs b/mix.exs index 130f1ccc..6286042d 100644 --- a/mix.exs +++ b/mix.exs @@ -11,7 +11,8 @@ defmodule Philomena.MixProject do start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps(), - dialyzer: [plt_add_apps: [:mix]] + dialyzer: [plt_add_apps: [:mix]], + docs: [formatters: ["html"]] ] end @@ -34,7 +35,7 @@ defmodule Philomena.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.6"}, + {:phoenix, "~> 1.7"}, {:phoenix_pubsub, "~> 2.1"}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.9"}, @@ -44,10 +45,8 @@ defmodule Philomena.MixProject do {:phoenix_live_reload, "~> 1.4", only: :dev}, {:gettext, "~> 0.22"}, {:jason, "~> 1.4"}, - {:ranch, "~> 2.1", override: true}, - {:plug_cowboy, "~> 2.6"}, - {:slime, "~> 1.3.0", - github: "liamwhite/slime", ref: "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31", override: true}, + {:bandit, "~> 1.2"}, + {:slime, "~> 1.3.1"}, {:phoenix_slime, "~> 0.13", github: "slime-lang/phoenix_slime", ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"}, {:phoenix_pubsub_redis, "~> 3.0"}, @@ -64,9 +63,7 @@ defmodule Philomena.MixProject do {:redix, "~> 1.2"}, {:remote_ip, "~> 1.1"}, {:briefly, "~> 0.4"}, - {:tesla, "~> 1.5"}, - {:castore, "~> 1.0", override: true}, - {:mint, "~> 1.4"}, + {:req, "~> 0.5"}, {:exq, "~> 0.17"}, {:ex_aws, "~> 2.0", github: "liamwhite/ex_aws", ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4", override: true}, @@ -83,9 +80,10 @@ defmodule Philomena.MixProject do {:rustler, "~> 0.27"}, # Linting - {:credo, "~> 1.6", only: [:dev, :test], override: true}, + {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:credo_envvar, "~> 0.1", only: [:dev, :test], runtime: false}, {:credo_naming, "~> 2.0", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.30", only: [:dev], runtime: false}, # Security checks {:sobelow, "~> 0.11", only: [:dev, :test], runtime: true}, @@ -94,10 +92,6 @@ defmodule Philomena.MixProject do # Static analysis {:dialyxir, "~> 1.2", only: :dev, runtime: false}, - # Fixes for OTP/25 - {:neotoma, "~> 1.7.3", manager: :rebar3, override: true}, - {:hut, "~> 1.4.0", manager: :rebar3, override: true}, - # Fixes for Elixir v1.15+ {:canary, "~> 1.1", github: "marcinkoziej/canary", ref: "704debde7a2c0600f78c687807884bf37c45bd79"} diff --git a/mix.lock b/mix.lock index 73184f5a..389ea193 100644 --- a/mix.lock +++ b/mix.lock @@ -1,90 +1,92 @@ %{ + "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"}, "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"}, "canary": {:git, "https://github.com/marcinkoziej/canary.git", "704debde7a2c0600f78c687807884bf37c45bd79", [ref: "704debde7a2c0600f78c687807884bf37c45bd79"]}, - "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, - "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, - "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"}, "elastix": {:hex, :elastix, "0.10.0", "7567da885677ba9deffc20063db5f3ca8cd10f23cff1ab3ed9c52b7063b7e340", [:mix], [{:httpoison, "~> 1.4", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:retry, "~> 0.8", [hex: :retry, repo: "hexpm", optional: false]}], "hexpm", "5fb342ce068b20f7845f5dd198c2dc80d967deafaa940a6e51b846db82696d1d"}, - "elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"}, + "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_aws": {:git, "https://github.com/liamwhite/ex_aws.git", "a340859dd8ac4d63bd7a3948f0994e493e49bda4", [ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4"]}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"}, + "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, "exq": {:hex, :exq, "0.19.0", "06eb92944dad39f0954dc8f63190d3e24d11734eef88cf5800883e57ebf74f3c", [:mix], [{:elixir_uuid, ">= 1.2.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0 and < 6.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:redix, ">= 0.9.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "24fc0ebdd87cc7406e1034fb46c2419f9c8a362f0ec634d23b6b819514d36390"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "hut": {:hex, :hut, "1.4.0", "7a1238ec00f95c9ec75412587ee11ac652eca308a7f4b8cc9629746d579d6cf0", [:"erlang.mk", :rebar3], [], "hexpm", "7af8704b9bae98a336f70d9560fc3c97f15665265fa603dbd05352e63d6ebb03"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, - "mix_audit": {:hex, :mix_audit, "2.1.2", "6cd5c5e2edbc9298629c85347b39fb3210656e541153826efd0b2a63767f3395", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "68d2f06f96b9c445a23434c9d5f09682866a5b4e90f631829db1c64f140e795b"}, - "mua": {:hex, :mua, "0.2.1", "7f1c20dbe7266d514a07bf5b7a3946413d70150be41cb5475b5a95bb517a378f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0bc556803a1d09dfb69bfebecb838cf33a2d123de84f700c41b6b8134027c11f"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"}, + "mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"}, + "mua": {:hex, :mua, "0.2.2", "d2997abc1eee43d91e4a355665658743ad2609b8d5992425940ce17b7ff87933", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "cda7e38c65d3105b3017b25ac402b4c9457892abeb2e11c331b25a92d16b04c0"}, "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, - "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]}, - "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.5.0", "1a1f841ccda19b15f1d82968840a5b895c5f687b6734e430e4b2dbe035ca1837", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "13990570fde09e16959ef214501fe2813e1192d62ca753ec8798980580436f94"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.1", "6ab463cf43938ee9906067b33c8d66782343de4280a70084cd5617accc6345a8", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "e8467d308b61f294f68afe12c81bf585584c7ceed40ec8adde88ec176d480a78"}, + "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"}, "phoenix_slime": {:git, "https://github.com/slime-lang/phoenix_slime.git", "8944de91654d6fcf6bdcc0aed6b8647fe3398241", [ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"]}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, - "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.0", "3ae9369c60641084363b08fe90267cbdd316df57e3557ea522114b30b63256ea", [:mix], [{:cowboy, "~> 2.7.0 or ~> 2.8.0 or ~> 2.9.0 or ~> 2.10.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d85444fb8aa1f2fc62eabe83bbe387d81510d773886774ebdcb429b3da3c1a4a"}, - "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, + "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, "pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"}, "qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"}, - "ranch": {:hex, :ranch, "2.1.0", "2261f9ed9574dcfcc444106b9f6da155e6e540b2f82ba3d42b339b93673b72a3", [:make, :rebar3], [], "hexpm", "244ee3fa2a6175270d8e1fc59024fd9dbc76294a321057de8f803b1479e76916"}, - "redix": {:hex, :redix, "1.3.0", "f4121163ff9d73bf72157539ff23b13e38422284520bb58c05e014b19d6f0577", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "60d483d320c77329c8cbd3df73007e51b23f3fae75b7693bc31120d83ab26131"}, - "remote_ip": {:hex, :remote_ip, "1.1.0", "cb308841595d15df3f9073b7c39243a1dd6ca56e5020295cb012c76fbec50f2d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "616ffdf66aaad6a72fc546dabf42eed87e2a99e97b09cbd92b10cc180d02ed74"}, + "redix": {:hex, :redix, "1.5.1", "a2386971e69bf23630fb3a215a831b5478d2ee7dc9ea7ac811ed89186ab5d7b7", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "85224eb2b683c516b80d472eb89b76067d5866913bf0be59d646f550de71f5c4"}, + "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, + "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"}, "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, - "rustler": {:hex, :rustler, "0.31.0", "7e5eefe61e6e6f8901e5aa3de60073d360c6320d9ec363027b0197297b80c46a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "99e378459bfb9c3bda6d3548b2b3bc6f9ad97f728f76bdbae7bf5c770a4f8abd"}, + "rustler": {:hex, :rustler, "0.33.0", "4a5b0a7a7b0b51549bea49947beff6fae9bc5d5326104dcd4531261e876b5619", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "7c4752728fee59a815ffd20c3429c55b644041f25129b29cdeb5c470b80ec5fd"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, "secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"}, - "slime": {:git, "https://github.com/liamwhite/slime.git", "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31", [ref: "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31"]}, + "slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, + "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, } diff --git a/test/philomena_web/user_auth_test.exs b/test/philomena_web/user_auth_test.exs index b66e5046..441df85d 100644 --- a/test/philomena_web/user_auth_test.exs +++ b/test/philomena_web/user_auth_test.exs @@ -9,6 +9,7 @@ defmodule PhilomenaWeb.UserAuthTest do conn = conn |> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base)) + |> assign(:fingerprint, "d015c342859dde3") |> init_test_session(%{}) %{user: user_fixture(), conn: conn} diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index aa9df751..dd4240a4 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -41,9 +41,11 @@ defmodule PhilomenaWeb.ConnCase do |> Philomena.Filters.change_filter() |> Philomena.Repo.insert!() + fingerprint = to_string(:io_lib.format(~c"d~14.16.0b", [:rand.uniform(2 ** 53)])) + conn = Phoenix.ConnTest.build_conn() - |> Phoenix.ConnTest.put_req_cookie("_ses", Integer.to_string(System.unique_integer())) + |> Phoenix.ConnTest.put_req_cookie("_ses", fingerprint) {:ok, conn: conn} end