Merge remote-tracking branch 'origin/master' into redesign

This commit is contained in:
Luna D. 2024-06-23 14:16:23 +02:00
commit ec6c51b7e2
No known key found for this signature in database
GPG key ID: 4B1C63448394F688
91 changed files with 1412 additions and 781 deletions

View file

@ -14,8 +14,19 @@ jobs:
with: with:
path: | path: |
_build _build
.cargo
deps 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 pull
- run: docker compose build - run: docker compose build
@ -27,6 +38,18 @@ jobs:
run: | run: |
docker compose run app mix sobelow --config docker compose run app mix sobelow --config
docker compose run app mix deps.audit 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: lint-and-test:
name: 'JavaScript Linting and Unit Tests' name: 'JavaScript Linting and Unit Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest

10
.typos.toml Normal file
View file

@ -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]+"
]

View file

@ -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 = `
<div class="image-container" data-image-id="1" data-image-tags="[1]">
<div class="js-spoiler-info-overlay"></div>
<picture><img src=""/></picture>
</div>
`;
return [ element, assertNotNull($<HTMLDivElement>('.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 = `
<div class="image-show-container" data-image-id="1" data-image-tags="[1]">
<div class="image-filtered hidden">
<img src=""/>
<span class="filter-explanation"></span>
</div>
<div class="image-show hidden">
<picture><img src=""/></picture>
</div>
</div>
`;
return [
element,
assertNotNull($<HTMLDivElement>('.image-filtered', element)),
assertNotNull($<HTMLDivElement>('.image-show', element)),
assertNotNull($<HTMLSpanElement>('.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([]);
});
});

View file

@ -1,15 +1,14 @@
import { assertNotNull } from './utils/assert';
import { delegate, leftClick } from './utils/events'; import { delegate, leftClick } from './utils/events';
import { clearEl, makeEl } from './utils/dom'; import { clearEl, makeEl } from './utils/dom';
function insertCaptcha(_event: Event, target: HTMLInputElement) { function insertCaptcha(_event: Event, target: HTMLInputElement) {
const { parentElement, dataset: { sitekey } } = target; const parentElement = assertNotNull(target.parentElement);
if (!parentElement) { return; }
const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true}); const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true});
const frame = makeEl('div', {className: 'h-captcha'}); const frame = makeEl('div', {className: 'h-captcha'});
frame.dataset.sitekey = sitekey; frame.dataset.sitekey = target.dataset.sitekey;
clearEl(parentElement); clearEl(parentElement);

View file

@ -2,9 +2,10 @@
* Interactive behavior for duplicate reports. * Interactive behavior for duplicate reports.
*/ */
import { assertNotNull } from './utils/assert';
import { $, $$ } from './utils/dom'; import { $, $$ } from './utils/dom';
function setupDupeReports() { export function setupDupeReports() {
const onion = $<SVGSVGElement>('.onion-skin__image'); const onion = $<SVGSVGElement>('.onion-skin__image');
const slider = $<HTMLInputElement>('.onion-skin__slider'); const slider = $<HTMLInputElement>('.onion-skin__slider');
const swipe = $<SVGSVGElement>('.swipe__image'); const swipe = $<SVGSVGElement>('.swipe__image');
@ -30,16 +31,12 @@ function setupSwipe(swipe: SVGSVGElement) {
} }
function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) { function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) {
const target = $<HTMLImageElement>('#target', onion); const target = assertNotNull($<SVGImageElement>('#target', onion));
function setOpacity() { function setOpacity() {
if (target) {
target.setAttribute('opacity', slider.value); target.setAttribute('opacity', slider.value);
} }
}
setOpacity(); setOpacity();
slider.addEventListener('input', setOpacity); slider.addEventListener('input', setOpacity);
} }
export { setupDupeReports };

View file

@ -1,36 +1,39 @@
/** /**
* FP version 4 * FP version 4
* *
* Not reliant on deprecated properties, * Not reliant on deprecated properties, and potentially
* and potentially more accurate at what it's supposed to do. * more accurate at what it's supposed to do.
*/ */
import { $ } from './utils/dom';
import store from './utils/store'; import store from './utils/store';
interface RealKeyboard { const storageKey = 'cached_ses_value';
declare global {
interface Keyboard {
getLayoutMap: () => Promise<Map<string, string>> getLayoutMap: () => Promise<Map<string, string>>
} }
interface RealUserAgentData { interface UserAgentData {
brands: [{brand: string, version: string}], brands: [{brand: string, version: string}],
mobile: boolean, mobile: boolean,
platform: string, platform: string,
} }
interface RealNavigator extends Navigator { interface Navigator {
deviceMemory: number | null, deviceMemory: number | undefined,
keyboard: RealKeyboard | null, keyboard: Keyboard | undefined,
userAgentData: RealUserAgentData | null, 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 str The string to hash.
* @param {number} seed The seed to use for hash generation. * @param seed The seed to use for hash generation.
* @return {number} The resulting hash as a 53-bit number. * @return The resulting hash as a 53-bit number.
* @see {@link https://stackoverflow.com/a/8831937} * @see {@link https://stackoverflow.com/a/52171480}
*/ */
function cyrb53(str: string, seed: number = 0x16fe7b0a): number { function cyrb53(str: string, seed: number = 0x16fe7b0a): number {
let h1 = 0xdeadbeef ^ seed; let h1 = 0xdeadbeef ^ seed;
@ -50,67 +53,104 @@ function cyrb53(str: string, seed: number = 0x16fe7b0a): number {
return 4294967296 * (2097151 & h2) + (h1 >>> 0); 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 String containing layout map entries, or `none` when unavailable
* @return {Promise<string>} Hexadecimally encoded 53 bit number padded to 7 bytes.
*/ */
async function createFp(): Promise<string> { async function getKeyboardData(): Promise<string> {
const nav = navigator as RealNavigator; if (navigator.keyboard) {
let kb = 'none'; const layoutMap = await navigator.keyboard.getLayoutMap();
let mem = '1';
let ua = 'none';
if (nav.keyboard) { return Array.from(layoutMap.entries())
kb = Array.from((await nav.keyboard.getLayoutMap()).entries()).sort().map(e => `${e[0]}${e[1]}`).join(''); .sort()
.map(([k, v]) => `${k}${v}`)
.join('');
} }
if (nav.deviceMemory) { return 'none';
mem = nav.deviceMemory.toString();
} }
if (nav.userAgentData) { /**
const uadata = nav.userAgentData; * 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();
}
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'; let brands = 'none';
if (uadata.brands && uadata.brands.length > 0) { if (data.brands && data.brands.length > 0) {
brands = uadata.brands.filter(e => !e.brand.match(/.*ot.*rand.*/gi)).map(e => `${e.brand}${e.version}`).join(''); // 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'); return 'none';
const body = $<HTMLBodyElement>('body'); }
if (!width && body) { /**
* 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'); const testElement = document.createElement('span');
testElement.style.minWidth = '1rem'; testElement.style.minWidth = '1rem';
testElement.style.maxWidth = '1rem'; testElement.style.maxWidth = '1rem';
testElement.style.position = 'absolute'; 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); return width;
}
if (!width) {
width = '0';
} }
/**
* Create a semi-unique string from browser attributes.
*
* @return Hexadecimally encoded 53 bit number padded to 7 bytes.
*/
async function createFp(): Promise<string> {
const prints: string[] = [ const prints: string[] = [
navigator.userAgent, navigator.userAgent,
navigator.hardwareConcurrency.toString(), navigator.hardwareConcurrency.toString(),
navigator.maxTouchPoints.toString(), navigator.maxTouchPoints.toString(),
navigator.language, navigator.language,
kb, await getKeyboardData(),
mem, getMemoryData(),
ua, getUserAgentBrands(),
width, getFontRemSize(),
screen.height.toString(), screen.height.toString(),
screen.width.toString(), screen.width.toString(),
@ -121,43 +161,50 @@ async function createFp(): Promise<string> {
new Date().getTimezoneOffset().toString(), 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. * Sets the `_ses` cookie.
* *
* If `cached_ses_value` is present in local storage, uses it to set 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, if the `_ses` cookie already exists, uses its value instead.
* Otherwise attempts to generate a new value for the `_ses` cookie * Otherwise, attempts to generate a new value for the `_ses` cookie based on
* based on various browser attributes. * various browser attributes.
* Failing all previous methods, sets the `_ses` cookie to a fallback value. * Failing all previous methods, sets the `_ses` cookie to a fallback value.
*
* @async
*/ */
export async function setSesCookie() { export async function setSesCookie() {
let fp: string | null = store.get('cached_ses_value'); let sesValue = getSesValue();
if (!fp) { if (!sesValue || sesValue.charAt(0) !== 'd' || sesValue.length !== 15) {
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) {
// The prepended 'd' acts as a crude versioning mechanism. // The prepended 'd' acts as a crude versioning mechanism.
try { sesValue = `d${await createFp()}`;
fp = `d${await createFp()}`; store.set(storageKey, sesValue);
}
// If it fails, use fakeprint "d015c342859dde3" as a last resort.
catch {
fp = 'd015c342859dde3';
} }
store.set('cached_ses_value', fp); document.cookie = `_ses=${sesValue}; path=/; SameSite=Lax`;
}
document.cookie = `_ses=${fp}; path=/; SameSite=Lax`;
} }

View file

@ -3,6 +3,7 @@
*/ */
import { arraysEqual } from './utils/array'; import { arraysEqual } from './utils/array';
import { assertNotNull, assertNotUndefined } from './utils/assert';
import { $, $$ } from './utils/dom'; import { $, $$ } from './utils/dom';
import { initDraggables } from './utils/draggable'; import { initDraggables } from './utils/draggable';
import { fetchJson } from './utils/requests'; import { fetchJson } from './utils/requests';
@ -11,14 +12,13 @@ export function setupGalleryEditing() {
if (!$<HTMLElement>('.rearrange-button')) return; if (!$<HTMLElement>('.rearrange-button')) return;
const [ rearrangeEl, saveEl ] = $$<HTMLElement>('.rearrange-button'); const [ rearrangeEl, saveEl ] = $$<HTMLElement>('.rearrange-button');
const sortableEl = $<HTMLDivElement>('#sortable'); const sortableEl = assertNotNull($<HTMLDivElement>('#sortable'));
const containerEl = $<HTMLDivElement>('.media-list'); const containerEl = assertNotNull($<HTMLDivElement>('.js-resizable-media-container'));
if (!sortableEl || !containerEl || !saveEl || !rearrangeEl) { return; }
// Copy array // Copy array
let oldImages = window.booru.galleryImages.slice(); const galleryImages = assertNotUndefined(window.booru.galleryImages);
let newImages = window.booru.galleryImages.slice(); let oldImages = galleryImages.slice();
let newImages = galleryImages.slice();
initDraggables(); initDraggables();
@ -33,17 +33,17 @@ export function setupGalleryEditing() {
sortableEl.classList.remove('editing'); sortableEl.classList.remove('editing');
containerEl.classList.remove('drag-container'); containerEl.classList.remove('drag-container');
newImages = $$<HTMLDivElement>('.image-container', containerEl).map(i => parseInt(i.dataset.imageId || '-1', 10)); newImages = $$<HTMLDivElement>('.image-container', containerEl)
.map(i => parseInt(assertNotUndefined(i.dataset.imageId), 10));
// If nothing changed, don't bother. // If nothing changed, don't bother.
if (arraysEqual(newImages, oldImages)) return; if (arraysEqual(newImages, oldImages)) return;
if (saveEl.dataset.reorderPath) { const reorderPath = assertNotUndefined(saveEl.dataset.reorderPath);
fetchJson('PATCH', saveEl.dataset.reorderPath, {
image_ids: newImages,
fetchJson('PATCH', reorderPath, {
image_ids: newImages,
// copy the array again so that we have the newly updated set // copy the array again so that we have the newly updated set
}).then(() => oldImages = newImages.slice()); }).then(() => oldImages = newImages.slice());
}
}); });
} }

View file

@ -2,131 +2,109 @@
* Client-side image filtering/spoilering. * Client-side image filtering/spoilering.
*/ */
import { assertNotUndefined } from './utils/assert';
import { $$, escapeHtml } from './utils/dom'; import { $$, escapeHtml } from './utils/dom';
import { setupInteractions } from './interactions'; import { setupInteractions } from './interactions';
import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image'; import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag'; import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
import { AstMatcher } from './query/types'; 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; }
runCallback(img, test as TagData[]);
// I don't like this.
window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(img.dataset.imageId));
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; return true;
} }
// --- // No tags matched, try complex filter AST
const hitComplex = imageHitsComplex(img, complex);
function filterThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) { if (hitComplex) {
hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); runCallback(img, hitTags, 'complex');
return true;
} }
function spoilerThumbSimple(img: HTMLDivElement, tagsHit: TagData[]) { // Nothing matched at all, image can be shown
spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); return false;
})();
if (hit) {
// Disallow negative interaction on image which is not visible
window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(img.dataset.imageId));
} }
function filterThumbComplex(img: HTMLDivElement) { return hit;
hideThumb(img, window.booru.hiddenTag, '[HIDDEN] <i>(Complex Filter)</i>');
} }
function spoilerThumbComplex(img: HTMLDivElement) { function bannerImage(tagsHit: TagData[]) {
spoilerThumb(img, window.booru.hiddenTag, '<i>(Complex Filter)</i>'); if (tagsHit.length > 0) {
return tagsHit[0].spoiler_image_uri || window.booru.hiddenTag;
} }
function filterBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { return window.booru.hiddenTag;
spoilerBlock(
img,
tagsHit[0].spoiler_image_uri || window.booru.hiddenTag,
`This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `
);
} }
function spoilerBlockSimple(img: HTMLDivElement, tagsHit: TagData[]) { // TODO: this approach is not suitable for translations because it depends on
spoilerBlock( // markup embedded in the page adjacent to this text
img,
tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, /* eslint-disable indent */
`This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `
); function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}`
: '[HIDDEN] <i>(Complex Filter)</i>';
hideThumb(img, bannerImage(tagsHit), bannerText);
} }
function filterBlockComplex(img: HTMLDivElement) { function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); const bannerText = type === 'tags' ? displayTags(tagsHit)
: '<i>(Complex Filter)</i>';
spoilerThumb(img, bannerImage(tagsHit), bannerText);
} }
function spoilerBlockComplex(img: HTMLDivElement) { function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `
: 'This image was hidden by a complex tag expression in ';
spoilerBlock(img, bannerImage(tagsHit), bannerText);
} }
// --- function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `
function thumbTagFilter(tags: TagData[], img: HTMLDivElement) { : 'This image was spoilered by a complex tag expression in ';
return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); spoilerBlock(img, bannerImage(tagsHit), bannerText);
} }
function thumbComplexFilter(complex: AstMatcher, img: HTMLDivElement) { /* eslint-enable indent */
return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex);
}
function thumbTagSpoiler(tags: TagData[], img: HTMLDivElement) { export function filterNode(node: Pick<Document, 'querySelectorAll'>) {
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<Document, 'querySelectorAll'>) {
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
const { hiddenFilter, spoileredFilter } = window.booru; const { hiddenFilter, spoileredFilter } = window.booru;
// Image thumb boxes with vote and fave buttons on them // Image thumb boxes with vote and fave buttons on them
$$<HTMLDivElement>('.image-container', node) $$<HTMLDivElement>('.image-container', node)
.filter(img => !thumbTagFilter(hiddenTags, img)) .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped))
.filter(img => !thumbComplexFilter(hiddenFilter, img)) .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped))
.filter(img => !thumbTagSpoiler(spoileredTags, img))
.filter(img => !thumbComplexSpoiler(spoileredFilter, img))
.forEach(img => showThumb(img)); .forEach(img => showThumb(img));
// Individual image pages and images in posts/comments // Individual image pages and images in posts/comments
$$<HTMLDivElement>('.image-show-container', node) $$<HTMLDivElement>('.image-show-container', node)
.filter(img => !blockTagFilter(hiddenTags, img)) .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped))
.filter(img => !blockComplexFilter(hiddenFilter, img)) .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped))
.filter(img => !blockTagSpoiler(spoileredTags, img))
.filter(img => !blockComplexSpoiler(spoileredFilter, img))
.forEach(img => showBlock(img)); .forEach(img => showBlock(img));
} }
function initImagesClientside() { export function initImagesClientside() {
window.booru.imagesWithDownvotingDisabled = []; window.booru.imagesWithDownvotingDisabled = [];
// This fills the imagesWithDownvotingDisabled array // This fills the imagesWithDownvotingDisabled array
filterNode(document); filterNode(document);
// Once the array is populated, we can initialize interactions // Once the array is populated, we can initialize interactions
setupInteractions(); setupInteractions();
} }
export { initImagesClientside, filterNode };

View file

@ -4,9 +4,9 @@
* Warn users that their PM will be reviewed. * 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 = $<HTMLTextAreaElement>('.js-toolbar-input'); const textarea = $<HTMLTextAreaElement>('.js-toolbar-input');
const warning = $<HTMLDivElement>('.js-hidden-warning'); const warning = $<HTMLDivElement>('.js-hidden-warning');
const imageEmbedRegex = /!+\[/g; const imageEmbedRegex = /!+\[/g;
@ -17,12 +17,10 @@ function warnAboutPMs() {
const value = textarea.value; const value = textarea.value;
if (value.match(imageEmbedRegex)) { if (value.match(imageEmbedRegex)) {
warning.classList.remove('hidden'); showEl(warning);
} }
else if (!warning.classList.contains('hidden')) { else if (!warning.classList.contains('hidden')) {
warning.classList.add('hidden'); hideEl(warning);
} }
}); });
} }
export { warnAboutPMs };

View file

@ -2,15 +2,17 @@
* Settings. * Settings.
*/ */
import { assertNotNull, assertNotUndefined } from './utils/assert';
import { $, $$ } from './utils/dom'; import { $, $$ } from './utils/dom';
import store from './utils/store'; import store from './utils/store';
export function setupSettings() { export function setupSettings() {
if (!$<HTMLElement>('#js-setting-table')) return;
if (!$('#js-setting-table')) return;
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]'); const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
const themeSelect = $<HTMLSelectElement>('#user_theme'); const themeSelect = assertNotNull($<HTMLSelectElement>('#user_theme'));
const styleSheet = $<HTMLLinkElement>('head link[rel="stylesheet"]'); const styleSheet = assertNotNull($<HTMLLinkElement>('head link[rel="stylesheet"]'));
// Local settings // Local settings
localCheckboxes.forEach(checkbox => { localCheckboxes.forEach(checkbox => {
@ -22,9 +24,7 @@ export function setupSettings() {
// Theme preview // Theme preview
if (themeSelect) { if (themeSelect) {
themeSelect.addEventListener('change', () => { themeSelect.addEventListener('change', () => {
if (styleSheet) { styleSheet.href = assertNotUndefined(themeSelect.options[themeSelect.selectedIndex].dataset.themePath);
styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath || '#';
}
}); });
} }
} }

View file

@ -4,9 +4,7 @@
import { $ } from './utils/dom'; import { $ } from './utils/dom';
interface ShortcutKeycodes { type ShortcutKeyMap = Record<string, () => void>;
[key: string]: () => void
}
function getHover(): string | null { function getHover(): string | null {
const thumbBoxHover = $<HTMLDivElement>('.media-box:hover'); const thumbBoxHover = $<HTMLDivElement>('.media-box:hover');
@ -45,7 +43,7 @@ function isOK(event: KeyboardEvent): boolean {
document.activeElement.tagName !== 'TEXTAREA'; document.activeElement.tagName !== 'TEXTAREA';
} }
const keyCodes: ShortcutKeycodes = { const keyCodes: ShortcutKeyMap = {
KeyJ() { click('.js-prev'); }, // J - go to previous image KeyJ() { click('.js-prev'); }, // J - go to previous image
KeyI() { click('.js-up'); }, // I - go to index page KeyI() { click('.js-up'); }, // I - go to index page
KeyK() { click('.js-next'); }, // K - go to next image KeyK() { click('.js-next'); }, // K - go to next image
@ -55,17 +53,16 @@ const keyCodes: ShortcutKeycodes = {
KeyO() { openFullView(); }, // O - open original KeyO() { openFullView(); }, // O - open original
KeyV() { openFullViewNewTab(); }, // V - open original in a new tab KeyV() { openFullViewNewTab(); }, // V - open original in a new tab
KeyF() { // F - favourite image KeyF() { // F - favourite image
/* Gotta use a "return" here and in the next function because eslint is silly */ click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]`
return getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`) : '.block__header a.interaction--fave');
: click('.block__header a.interaction--fave');
}, },
KeyU() { // U - upvote image KeyU() { // U - upvote image
return getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`) click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]`
: click('.block__header a.interaction--upvote'); : '.block__header a.interaction--upvote');
}, },
}; };
function listenForKeys() { export function listenForKeys() {
document.addEventListener('keydown', (event: KeyboardEvent) => { document.addEventListener('keydown', (event: KeyboardEvent) => {
if (isOK(event) && keyCodes[event.code]) { if (isOK(event) && keyCodes[event.code]) {
keyCodes[event.code](); keyCodes[event.code]();
@ -73,5 +70,3 @@ function listenForKeys() {
} }
}); });
} }
export { listenForKeys };

View file

@ -1,5 +1,7 @@
import { assertNotNull } from './utils/assert';
import { $ } from './utils/dom'; import { $ } from './utils/dom';
import { inputDuplicatorCreator } from './input-duplicator'; import { inputDuplicatorCreator } from './input-duplicator';
import '../types/ujs';
export interface TagSourceEvent extends CustomEvent<Response> { export interface TagSourceEvent extends CustomEvent<Response> {
target: HTMLElement, target: HTMLElement,
@ -14,19 +16,17 @@ function setupInputs() {
}); });
} }
function imageSourcesCreator() { export function imageSourcesCreator() {
setupInputs(); setupInputs();
document.addEventListener('fetchcomplete', (({ target, detail }: TagSourceEvent) => { document.addEventListener('fetchcomplete', ({ target, detail }) => {
const sourceSauce = $<HTMLElement>('.js-sourcesauce'); if (target.matches('#source-form')) {
const sourceSauce = assertNotNull($<HTMLElement>('.js-sourcesauce'));
if (sourceSauce && target && target.matches('#source-form')) {
detail.text().then(text => { detail.text().then(text => {
sourceSauce.outerHTML = text; sourceSauce.outerHTML = text;
setupInputs(); setupInputs();
}); });
} }
}) as EventListener); });
} }
export { imageSourcesCreator };

View file

@ -4,12 +4,10 @@
* Hide staff elements if enabled in the settings. * Hide staff elements if enabled in the settings.
*/ */
import { $$ } from './utils/dom'; import { $$, hideEl } from './utils/dom';
export function hideStaffTools() { export function hideStaffTools() {
if (window.booru.hideStaffTools === 'true') { if (window.booru.hideStaffTools === 'true') {
$$<HTMLElement>('.js-staff-action').forEach(el => { $$<HTMLElement>('.js-staff-action').forEach(el => hideEl(el));
el.classList.add('hidden');
});
} }
} }

View file

@ -4,7 +4,7 @@
import { $$, showEl, hideEl } from './utils/dom'; import { $$, showEl, hideEl } from './utils/dom';
import { assertNotUndefined } from './utils/assert'; import { assertNotUndefined } from './utils/assert';
import { TagSourceEvent } from './sources'; import '../types/ujs';
type TagDropdownActionFunction = () => void; type TagDropdownActionFunction = () => void;
type TagDropdownActionList = Record<string, TagDropdownActionFunction>; type TagDropdownActionList = Record<string, TagDropdownActionFunction>;
@ -19,8 +19,8 @@ function removeTag(tagId: number, list: number[]) {
function createTagDropdown(tag: HTMLSpanElement) { function createTagDropdown(tag: HTMLSpanElement) {
const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru; const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link'); const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link', tag);
const [ unwatched, watched, spoilered, hidden ] = $$<HTMLSpanElement>('.tag__state'); const [ unwatched, watched, spoilered, hidden ] = $$<HTMLSpanElement>('.tag__state', tag);
const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10); const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10);
const actions: TagDropdownActionList = { const actions: TagDropdownActionList = {
@ -56,15 +56,14 @@ function createTagDropdown(tag: HTMLSpanElement) {
if (userIsSignedIn && if (userIsSignedIn &&
!userCanEditFilter) showEl(filter); !userCanEditFilter) showEl(filter);
tag.addEventListener('fetchcomplete', ((event: TagSourceEvent) => { tag.addEventListener('fetchcomplete', event => {
const act = event.target.dataset.tagAction; const act = assertNotUndefined(event.target.dataset.tagAction);
if (act && actions[act]) {
actions[act](); actions[act]();
} });
}) as EventListener);
} }
export function initTagDropdown() { export function initTagDropdown() {
[].forEach.call($$<HTMLSpanElement>('.tag.dropdown'), createTagDropdown); for (const tagSpan of $$<HTMLSpanElement>('.tag.dropdown')) {
createTagDropdown(tagSpan);
}
} }

View file

@ -2,42 +2,37 @@
* Tags Misc * Tags Misc
*/ */
import { assertType, assertNotNull } from './utils/assert';
import { $, $$ } from './utils/dom'; import { $, $$ } from './utils/dom';
import store from './utils/store'; import store from './utils/store';
import { initTagDropdown } from './tags'; import { initTagDropdown } from './tags';
import { setupTagsInput, reloadTagsInput } from './tagsinput'; import { setupTagsInput, reloadTagsInput } from './tagsinput';
import { TagSourceEvent } from './sources'; import '../types/ujs';
type TagInputActionFunction = (tagInput: HTMLTextAreaElement | null) => void type TagInputActionFunction = (tagInput: HTMLTextAreaElement) => void;
type TagInputActionList = { type TagInputActionList = Record<string, TagInputActionFunction>;
save: TagInputActionFunction,
load: TagInputActionFunction, function tagInputButtons(event: MouseEvent) {
clear: TagInputActionFunction, const target = assertType(event.target, HTMLElement);
}
function tagInputButtons({target}: PointerEvent) {
const actions: TagInputActionList = { const actions: TagInputActionList = {
save(tagInput: HTMLTextAreaElement | null) { save(tagInput: HTMLTextAreaElement) {
if (tagInput) store.set('tag_input', tagInput.value); store.set('tag_input', tagInput.value);
}, },
load(tagInput: HTMLTextAreaElement | null) { load(tagInput: HTMLTextAreaElement) {
if (!tagInput) { return; }
// If entry 'tag_input' does not exist, try to use the current list // If entry 'tag_input' does not exist, try to use the current list
tagInput.value = store.get('tag_input') || tagInput.value; tagInput.value = store.get('tag_input') || tagInput.value;
reloadTagsInput(tagInput); reloadTagsInput(tagInput);
}, },
clear(tagInput: HTMLTextAreaElement | null) { clear(tagInput: HTMLTextAreaElement) {
if (!tagInput) { return; }
tagInput.value = ''; tagInput.value = '';
reloadTagsInput(tagInput); reloadTagsInput(tagInput);
}, },
}; };
for (const action in actions) { for (const [ name, action ] of Object.entries(actions)) {
if (target && (target as HTMLElement).matches(`#tagsinput-${action}`)) { if (target && target.matches(`#tagsinput-${name}`)) {
actions[action as keyof TagInputActionList]($<HTMLTextAreaElement>('image_tag_input')); action(assertNotNull($<HTMLTextAreaElement>('#image_tag_input')));
} }
} }
} }
@ -49,10 +44,10 @@ function setupTags() {
}); });
} }
function updateTagSauce({target, detail}: TagSourceEvent) { function updateTagSauce({ target, detail }: FetchcompleteEvent) {
const tagSauce = $<HTMLDivElement>('.js-tagsauce'); if (target.matches('#tags-form')) {
const tagSauce = assertNotNull($<HTMLDivElement>('.js-tagsauce'));
if (tagSauce && target.matches('#tags-form')) {
detail.text().then(text => { detail.text().then(text => {
tagSauce.outerHTML = text; tagSauce.outerHTML = text;
setupTags(); setupTags();
@ -63,8 +58,8 @@ function updateTagSauce({target, detail}: TagSourceEvent) {
function setupTagEvents() { function setupTagEvents() {
setupTags(); setupTags();
document.addEventListener('fetchcomplete', updateTagSauce as EventListener); document.addEventListener('fetchcomplete', updateTagSauce);
document.addEventListener('click', tagInputButtons as EventListener); document.addEventListener('click', tagInputButtons);
} }
export { setupTagEvents }; export { setupTagEvents };

View file

@ -28,12 +28,12 @@ describe('Local Autocompleter', () => {
}); });
describe('instantiation', () => { describe('instantiation', () => {
it('should be constructable with compatible data', () => { it('should be constructible with compatible data', () => {
const result = new LocalAutocompleter(mockData); const result = new LocalAutocompleter(mockData);
expect(result).toBeInstanceOf(LocalAutocompleter); expect(result).toBeInstanceOf(LocalAutocompleter);
}); });
it('should NOT be constructable with incompatible data', () => { it('should NOT be constructible with incompatible data', () => {
const versionDataOffset = 12; const versionDataOffset = 12;
const mockIncompatibleDataArray = new Array(versionDataOffset).fill(0); const mockIncompatibleDataArray = new Array(versionDataOffset).fill(0);
// Set data version to 1 // Set data version to 1
@ -45,6 +45,8 @@ describe('Local Autocompleter', () => {
}); });
describe('topK', () => { describe('topK', () => {
const termStem = ['f', 'o'].join('');
let localAc: LocalAutocompleter; let localAc: LocalAutocompleter;
beforeAll(() => { beforeAll(() => {
@ -66,7 +68,7 @@ describe('Local Autocompleter', () => {
}); });
it('should return suggestions sorted by image count', () => { it('should return suggestions sorted by image count', () => {
const result = localAc.topK('fo', defaultK); const result = localAc.topK(termStem, defaultK);
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ name: 'forest', imageCount: 3 }), expect.objectContaining({ name: 'forest', imageCount: 3 }),
expect.objectContaining({ name: 'fog', imageCount: 1 }), expect.objectContaining({ name: 'fog', imageCount: 1 }),
@ -82,13 +84,13 @@ describe('Local Autocompleter', () => {
}); });
it('should return only the required number of suggestions', () => { 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 })]); expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]);
}); });
it('should NOT return suggestions associated with hidden tags', () => { it('should NOT return suggestions associated with hidden tags', () => {
window.booru.hiddenTagList = [1]; window.booru.hiddenTagList = [1];
const result = localAc.topK('fo', defaultK); const result = localAc.topK(termStem, defaultK);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });

361
assets/package-lock.json generated
View file

@ -440,9 +440,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -455,9 +455,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -470,9 +470,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -485,9 +485,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -500,9 +500,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -515,9 +515,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -530,9 +530,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -545,9 +545,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -560,9 +560,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -575,9 +575,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -590,9 +590,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -605,9 +605,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -620,9 +620,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -635,9 +635,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -650,9 +650,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -665,9 +665,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -680,9 +680,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -695,9 +695,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -710,9 +710,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -725,9 +725,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -740,9 +740,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -755,9 +755,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -770,9 +770,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -818,11 +818,11 @@
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/config-array": {
"version": "0.15.1", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.15.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.16.0.tgz",
"integrity": "sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ==", "integrity": "sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg==",
"dependencies": { "dependencies": {
"@eslint/object-schema": "^2.1.3", "@eslint/object-schema": "^2.1.4",
"debug": "^4.3.1", "debug": "^4.3.1",
"minimatch": "^3.0.5" "minimatch": "^3.0.5"
}, },
@ -853,9 +853,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.4.0", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.5.0.tgz",
"integrity": "sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg==", "integrity": "sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w==",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
@ -1409,9 +1409,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.14.2", "version": "20.14.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz",
"integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
@ -1931,9 +1931,9 @@
"deprecated": "Use your platform's native atob() and btoa() methods instead" "deprecated": "Use your platform's native atob() and btoa() methods instead"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.11.3", "version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1959,9 +1959,12 @@
} }
}, },
"node_modules/acorn-walk": { "node_modules/acorn-walk": {
"version": "8.3.2", "version": "8.3.3",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@ -2178,9 +2181,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001632", "version": "1.0.30001636",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
"integrity": "sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg==", "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -2526,9 +2529,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.799", "version": "1.4.810",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.799.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.810.tgz",
"integrity": "sha512-3D3DwWkRTzrdEpntY0hMLYwj7SeBk1138CkPE8sBDSj3WzrzOiG2rHm3luw8jucpf+WiyLBCZyU9lMHyQI9M9Q==" "integrity": "sha512-Kaxhu4T7SJGpRQx99tq216gCq2nMxJo+uuT6uzz9l8TVN2stL7M06MIIXAtr9jsrLs2Glflgf2vMQRepxawOdQ=="
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@ -2566,9 +2569,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.20.2", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
@ -2577,29 +2580,29 @@
"node": ">=12" "node": ">=12"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2", "@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.20.2", "@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.20.2", "@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.20.2", "@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.20.2", "@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.20.2", "@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.20.2", "@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.20.2", "@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.20.2", "@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.20.2", "@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.20.2", "@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.20.2", "@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.20.2", "@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.20.2", "@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.20.2", "@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.20.2", "@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.20.2", "@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.20.2", "@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.20.2", "@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.20.2", "@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.20.2", "@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.20.2", "@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.20.2" "@esbuild/win32-x64": "0.21.5"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@ -2642,15 +2645,15 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.4.0", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.5.0.tgz",
"integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==", "integrity": "sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw==",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
"@eslint/config-array": "^0.15.1", "@eslint/config-array": "^0.16.0",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "9.4.0", "@eslint/js": "9.5.0",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.3.0", "@humanwhocodes/retry": "^0.3.0",
"@nodelib/fs.walk": "^1.2.8", "@nodelib/fs.walk": "^1.2.8",
@ -2662,7 +2665,7 @@
"eslint-scope": "^8.0.1", "eslint-scope": "^8.0.1",
"eslint-visitor-keys": "^4.0.0", "eslint-visitor-keys": "^4.0.0",
"espree": "^10.0.1", "espree": "^10.0.1",
"esquery": "^1.4.2", "esquery": "^1.5.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0", "file-entry-cache": "^8.0.0",
@ -2688,7 +2691,7 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://eslint.org/donate"
} }
}, },
"node_modules/eslint-plugin-vitest": { "node_modules/eslint-plugin-vitest": {
@ -2716,13 +2719,13 @@
} }
}, },
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/scope-manager": { "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/scope-manager": {
"version": "7.13.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz",
"integrity": "sha512-ZrMCe1R6a01T94ilV13egvcnvVJ1pxShkE0+NDjDzH4nvG1wXpwsVI5bZCvE7AEDH1mXEx5tJSVR68bLgG7Dng==", "integrity": "sha512-adbXNVEs6GmbzaCpymHQ0MB6E4TqoiVbC0iqG3uijR8ZYfpAXMGttouQzF4Oat3P2GxDVIrg7bMI/P65LiQZdg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "7.13.0", "@typescript-eslint/types": "7.13.1",
"@typescript-eslint/visitor-keys": "7.13.0" "@typescript-eslint/visitor-keys": "7.13.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || >=20.0.0"
@ -2733,9 +2736,9 @@
} }
}, },
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": { "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/types": {
"version": "7.13.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.1.tgz",
"integrity": "sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA==", "integrity": "sha512-7K7HMcSQIAND6RBL4kDl24sG/xKM13cA85dc7JnmQXw2cBDngg7c19B++JzvJHRG3zG36n9j1i451GBzRuHchw==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || >=20.0.0"
@ -2746,13 +2749,13 @@
} }
}, },
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/typescript-estree": { "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/typescript-estree": {
"version": "7.13.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.1.tgz",
"integrity": "sha512-cAvBvUoobaoIcoqox1YatXOnSl3gx92rCZoMRPzMNisDiM12siGilSM4+dJAekuuHTibI2hVC2fYK79iSFvWjw==", "integrity": "sha512-uxNr51CMV7npU1BxZzYjoVz9iyjckBduFBP0S5sLlh1tXYzHzgZ3BR9SVsNed+LmwKrmnqN3Kdl5t7eZ5TS1Yw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "7.13.0", "@typescript-eslint/types": "7.13.1",
"@typescript-eslint/visitor-keys": "7.13.0", "@typescript-eslint/visitor-keys": "7.13.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"globby": "^11.1.0", "globby": "^11.1.0",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -2774,15 +2777,15 @@
} }
}, },
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": { "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/utils": {
"version": "7.13.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.1.tgz",
"integrity": "sha512-jceD8RgdKORVnB4Y6BqasfIkFhl4pajB1wVxrF4akxD2QPM8GNYjgGwEzYS+437ewlqqrg7Dw+6dhdpjMpeBFQ==", "integrity": "sha512-h5MzFBD5a/Gh/fvNdp9pTfqJAbuQC4sCN2WzuXme71lqFJsZtLbjxfSk4r3p02WIArOF9N94pdsLiGutpDbrXQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.13.0", "@typescript-eslint/scope-manager": "7.13.1",
"@typescript-eslint/types": "7.13.0", "@typescript-eslint/types": "7.13.1",
"@typescript-eslint/typescript-estree": "7.13.0" "@typescript-eslint/typescript-estree": "7.13.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || >=20.0.0" "node": "^18.18.0 || >=20.0.0"
@ -2796,12 +2799,12 @@
} }
}, },
"node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/visitor-keys": { "node_modules/eslint-plugin-vitest/node_modules/@typescript-eslint/visitor-keys": {
"version": "7.13.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.1.tgz",
"integrity": "sha512-nxn+dozQx+MK61nn/JP+M4eCkHDSxSLDpgE3WcQo0+fkjEolnaB5jswvIKC4K56By8MMgIho7f1PVxERHEo8rw==", "integrity": "sha512-k/Bfne7lrP7hcb7m9zSsgcBmo+8eicqqfNAJ7uUY+jkTFpKeH2FSkWpFRtimBxgkyvqfu9jTPRbYOvud6isdXA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "7.13.0", "@typescript-eslint/types": "7.13.1",
"eslint-visitor-keys": "^3.4.3" "eslint-visitor-keys": "^3.4.3"
}, },
"engines": { "engines": {
@ -2875,11 +2878,11 @@
} }
}, },
"node_modules/espree": { "node_modules/espree": {
"version": "10.0.1", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
"dependencies": { "dependencies": {
"acorn": "^8.11.3", "acorn": "^8.12.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.0.0" "eslint-visitor-keys": "^4.0.0"
}, },
@ -5111,24 +5114,44 @@
} }
}, },
"node_modules/stylelint-config-recommended": { "node_modules/stylelint-config-recommended": {
"version": "14.0.0", "version": "14.0.1",
"resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.0.tgz", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz",
"integrity": "sha512-jSkx290CglS8StmrLp2TxAppIajzIBZKYm3IxT89Kg6fGlxbPiTiyH9PS5YUuVAFwaJLl1ikiXX0QWjI0jmgZQ==", "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==",
"dev": true, "dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/stylelint"
},
{
"type": "github",
"url": "https://github.com/sponsors/stylelint"
}
],
"engines": { "engines": {
"node": ">=18.12.0" "node": ">=18.12.0"
}, },
"peerDependencies": { "peerDependencies": {
"stylelint": "^16.0.0" "stylelint": "^16.1.0"
} }
}, },
"node_modules/stylelint-config-standard": { "node_modules/stylelint-config-standard": {
"version": "36.0.0", "version": "36.0.1",
"resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.0.tgz", "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz",
"integrity": "sha512-3Kjyq4d62bYFp/Aq8PMKDwlgUyPU4nacXsjDLWJdNPRUgpuxALu1KnlAHIj36cdtxViVhXexZij65yM0uNIHug==", "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==",
"dev": true, "dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/stylelint"
},
{
"type": "github",
"url": "https://github.com/sponsors/stylelint"
}
],
"dependencies": { "dependencies": {
"stylelint-config-recommended": "^14.0.0" "stylelint-config-recommended": "^14.0.1"
}, },
"engines": { "engines": {
"node": ">=18.12.0" "node": ">=18.12.0"
@ -5428,9 +5451,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.5", "version": "5.5.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5533,11 +5556,11 @@
"dev": true "dev": true
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.2.13", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz",
"integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", "integrity": "sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==",
"dependencies": { "dependencies": {
"esbuild": "^0.20.1", "esbuild": "^0.21.3",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"rollup": "^4.13.0" "rollup": "^4.13.0"
}, },
@ -5869,9 +5892,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.17.0", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },

View file

@ -9,6 +9,7 @@ window.booru = {
csrfToken: 'mockCsrfToken', csrfToken: 'mockCsrfToken',
hiddenTag: '/mock-tagblocked.svg', hiddenTag: '/mock-tagblocked.svg',
hiddenTagList: [], hiddenTagList: [],
hideStaffTools: 'true',
ignoredTagList: [], ignoredTagList: [],
imagesWithDownvotingDisabled: [], imagesWithDownvotingDisabled: [],
spoilerType: 'off', spoilerType: 'off',
@ -20,7 +21,6 @@ window.booru = {
spoileredFilter: matchNone(), spoileredFilter: matchNone(),
interactions: [], interactions: [],
tagsVersion: 5, tagsVersion: 5,
hideStaffTools: 'false',
galleryImages: [] galleryImages: []
}; };

View file

@ -72,7 +72,7 @@ interface BooruObject {
/** /**
* List of image IDs in the current gallery. * List of image IDs in the current gallery.
*/ */
galleryImages: number[] galleryImages?: number[];
} }
declare global { declare global {

11
assets/types/ujs.ts Normal file
View file

@ -0,0 +1,11 @@
export {};
declare global {
interface FetchcompleteEvent extends CustomEvent<Response> {
target: HTMLElement,
}
interface GlobalEventHandlersEventMap {
fetchcomplete: FetchcompleteEvent;
}
}

View file

@ -31,6 +31,7 @@ config :canary,
# Configures the endpoint # Configures the endpoint
config :philomena, PhilomenaWeb.Endpoint, config :philomena, PhilomenaWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [host: "localhost"], url: [host: "localhost"],
secret_key_base: "xZYTon09JNRrj8snd7KL31wya4x71jmo5aaSSRmw1dGjWLRmEwWMTccwxgsGFGjM", secret_key_base: "xZYTon09JNRrj8snd7KL31wya4x71jmo5aaSSRmw1dGjWLRmEwWMTccwxgsGFGjM",
render_errors: [view: PhilomenaWeb.ErrorView, accepts: ~w(html json)], render_errors: [view: PhilomenaWeb.ErrorView, accepts: ~w(html json)],
@ -46,8 +47,6 @@ config :phoenix, :template_engines,
slime: PhoenixSlime.Engine, slime: PhoenixSlime.Engine,
slimleex: PhoenixSlime.LiveViewEngine slimleex: PhoenixSlime.LiveViewEngine
config :tesla, adapter: Tesla.Adapter.Mint
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View file

@ -73,8 +73,7 @@ config :philomena, :s3_primary_options,
host: System.fetch_env!("S3_HOST"), host: System.fetch_env!("S3_HOST"),
port: System.fetch_env!("S3_PORT"), port: System.fetch_env!("S3_PORT"),
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY"), secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
http_opts: [timeout: 180_000, recv_timeout: 180_000]
config :philomena, :s3_primary_bucket, System.fetch_env!("S3_BUCKET") 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"), host: System.get_env("ALT_S3_HOST"),
port: System.get_env("ALT_S3_PORT"), port: System.get_env("ALT_S3_PORT"),
access_key_id: System.get_env("ALT_AWS_ACCESS_KEY_ID"), access_key_id: System.get_env("ALT_AWS_ACCESS_KEY_ID"),
secret_access_key: System.get_env("ALT_AWS_SECRET_ACCESS_KEY"), secret_access_key: System.get_env("ALT_AWS_SECRET_ACCESS_KEY")
http_opts: [timeout: 180_000, recv_timeout: 180_000]
config :philomena, :s3_secondary_bucket, System.get_env("ALT_S3_BUCKET") 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, config :elastix,
httpoison_options: [ssl: [verify: :verify_none]] httpoison_options: [ssl: [verify: :verify_none]]
config :ex_aws, :hackney_opts, config :ex_aws, http_client: PhilomenaMedia.Req
timeout: 180_000,
recv_timeout: 180_000,
use_default_pool: false,
pool: false
config :ex_aws, :retries, config :ex_aws, :retries,
max_attempts: 20, max_attempts: 20,

View file

@ -60,7 +60,7 @@ services:
- '5173:5173' - '5173:5173'
postgres: postgres:
image: postgres:16.2-alpine image: postgres:16.3-alpine
environment: environment:
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD=postgres
volumes: volumes:
@ -86,7 +86,7 @@ services:
driver: "none" driver: "none"
files: files:
image: andrewgaul/s3proxy:sha-ec12ae0 image: andrewgaul/s3proxy:sha-4175022
environment: environment:
- JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3 - JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3
volumes: volumes:

View file

@ -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 ADD https://api.github.com/repos/philomena-dev/FFmpeg/git/refs/heads/release/6.1 /tmp/ffmpeg_version.json
RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \ RUN (echo "https://github.com/philomena-dev/prebuilt-ffmpeg/raw/master"; cat /etc/apk/repositories) > /tmp/repositories \

View file

@ -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 APP_DIR
ARG S3_SCHEME ARG S3_SCHEME
ARG S3_HOST ARG S3_HOST

View file

@ -32,10 +32,7 @@ defmodule Philomena.Application do
PhilomenaWeb.AdvertUpdater, PhilomenaWeb.AdvertUpdater,
PhilomenaWeb.UserFingerprintUpdater, PhilomenaWeb.UserFingerprintUpdater,
PhilomenaWeb.UserIpUpdater, PhilomenaWeb.UserIpUpdater,
PhilomenaWeb.Endpoint, PhilomenaWeb.Endpoint
# Connection drainer for SIGTERM
{Plug.Cowboy.Drainer, refs: [PhilomenaWeb.Endpoint.HTTP]}
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View file

@ -12,7 +12,7 @@ defmodule Philomena.ArtistLinks.AutomaticVerifier do
end end
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) String.contains?(body, code)
end end

View file

@ -6,7 +6,7 @@ defmodule Philomena.Channels.PicartoChannel do
@api_online @api_online
|> PhilomenaProxy.Http.get() |> PhilomenaProxy.Http.get()
|> case do |> case do
{:ok, %Tesla.Env{body: body, status: 200}} -> {:ok, %{body: body, status: 200}} ->
body body
|> Jason.decode!() |> Jason.decode!()
|> Map.new(&{&1["name"], fetch(&1, now)}) |> Map.new(&{&1["name"], fetch(&1, now)})

View file

@ -6,7 +6,7 @@ defmodule Philomena.Channels.PiczelChannel do
@api_online @api_online
|> PhilomenaProxy.Http.get() |> PhilomenaProxy.Http.get()
|> case do |> case do
{:ok, %Tesla.Env{body: body, status: 200}} -> {:ok, %{body: body, status: 200}} ->
body body
|> Jason.decode!() |> Jason.decode!()
|> Map.new(&{&1["slug"], fetch(&1, now)}) |> Map.new(&{&1["slug"], fetch(&1, now)})

View file

@ -88,7 +88,7 @@ defmodule Philomena.Comments.Query do
defp parse(fields, context, query_string) do defp parse(fields, context, query_string) do
fields fields
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string, context) |> Parser.parse(query_string, context)
end end

View file

@ -29,7 +29,7 @@ defmodule Philomena.Filters.Query do
defp parse(fields, context, query_string) do defp parse(fields, context, query_string) do
fields fields
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string, context) |> Parser.parse(query_string, context)
end end

View file

@ -18,7 +18,7 @@ defmodule Philomena.Galleries.Query do
query_string = query_string || "" query_string = query_string || ""
fields() fields()
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string) |> Parser.parse(query_string)
end end
end end

View file

@ -937,7 +937,7 @@ defmodule Philomena.Images do
(source.sources ++ target.sources) (source.sources ++ target.sources)
|> Enum.map(fn s -> %Source{image_id: target.id, source: s.source} end) |> Enum.map(fn s -> %Source{image_id: target.id, source: s.source} end)
|> Enum.uniq() |> Enum.uniq()
|> Enum.take(10) |> Enum.take(15)
target target
|> Image.sources_changeset(sources) |> Image.sources_changeset(sources)

View file

@ -212,11 +212,13 @@ defmodule Philomena.Images.Image do
image image
|> cast(attrs, []) |> cast(attrs, [])
|> SourceDiffer.diff_input(old_sources, new_sources) |> SourceDiffer.diff_input(old_sources, new_sources)
|> validate_length(:sources, max: 15)
end end
def sources_changeset(image, new_sources) do def sources_changeset(image, new_sources) do
change(image) change(image)
|> put_assoc(:sources, new_sources) |> put_assoc(:sources, new_sources)
|> validate_length(:sources, max: 15)
end end
def tag_changeset(image, attrs, old_tags, new_tags, excluded_tags \\ []) do def tag_changeset(image, attrs, old_tags, new_tags, excluded_tags \\ []) do

View file

@ -140,7 +140,7 @@ defmodule Philomena.Images.Query do
defp parse(fields, context, query_string) do defp parse(fields, context, query_string) do
fields fields
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string, context) |> Parser.parse(query_string, context)
end end

View file

@ -86,7 +86,7 @@ defmodule Philomena.Posts.Query do
defp parse(fields, context, query_string) do defp parse(fields, context, query_string) do
fields fields
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string, context) |> Parser.parse(query_string, context)
end end

View file

@ -16,7 +16,7 @@ defmodule Philomena.Reports.Query do
def compile(query_string) do def compile(query_string) do
fields() fields()
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string || "", %{}) |> Parser.parse(query_string || "", %{})
end end
end end

View file

@ -251,7 +251,7 @@ defmodule Philomena.Tags do
|> where(tag_id: ^tag.id) |> where(tag_id: ^tag.id)
|> Repo.delete_all() |> Repo.delete_all()
# Update other assocations # Update other associations
ArtistLink ArtistLink
|> where(tag_id: ^tag.id) |> where(tag_id: ^tag.id)
|> Repo.update_all(set: [tag_id: target_tag.id]) |> Repo.update_all(set: [tag_id: target_tag.id])

View file

@ -19,7 +19,7 @@ defmodule Philomena.Tags.Query do
def compile(query_string) do def compile(query_string) do
fields() fields()
|> Parser.parser() |> Parser.new()
|> Parser.parse(query_string || "") |> Parser.parse(query_string || "")
end end
end end

View file

@ -88,7 +88,7 @@ defmodule Philomena.Users.UserNotifier do
Your account has been automatically locked due to too many attempts to sign in. 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} #{url}

View file

@ -54,7 +54,8 @@ defmodule PhilomenaMedia.Analyzers do
:error = Analyzers.analyze(file) :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(%Plug.Upload{path: path}), do: analyze(path)
def analyze(path) when is_binary(path) do def analyze(path) when is_binary(path) do

View file

@ -1,5 +1,8 @@
defmodule PhilomenaMedia.Analyzers.Analyzer do defmodule PhilomenaMedia.Analyzers.Analyzer do
@moduledoc false @moduledoc false
@doc """
Generate a `m:PhilomenaMedia.Analyzers.Result` for file at the given path.
"""
@callback analyze(Path.t()) :: PhilomenaMedia.Analyzers.Result.t() @callback analyze(Path.t()) :: PhilomenaMedia.Analyzers.Result.t()
end end

View file

@ -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

View file

@ -36,7 +36,7 @@ defmodule PhilomenaMedia.Intensities do
> #### Info {: .info} > #### 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. > media files of any type supported by this library, not just PNG or JPEG.
## Examples ## Examples

View file

@ -62,29 +62,31 @@ defmodule PhilomenaMedia.Processors do
alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm} alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm}
alias PhilomenaMedia.Mime alias PhilomenaMedia.Mime
# The name of a version, like :large @typedoc "The name of a version, like `:large`."
@type version_name :: atom() @type version_name :: atom()
@type dimensions :: {integer(), integer()} @type dimensions :: {integer(), integer()}
@type version_list :: [{version_name(), dimensions()}] @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() @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()} @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()]} @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()} @type replace_original :: {:replace_original, Path.t()}
# Apply the computed corner intensities @typedoc "Apply the computed corner intensities."
@type intensities :: {:intensities, Intensities.t()} @type intensities :: {:intensities, Intensities.t()}
# An edit script, representing the changes to apply to the storage backend @typedoc """
# after successful processing An edit script, representing the changes to apply to the storage backend
after successful processing.
"""
@type edit_script :: [thumbnails() | replace_original() | intensities()] @type edit_script :: [thumbnails() | replace_original() | intensities()]
@doc """ @doc """

View file

@ -5,17 +5,25 @@ defmodule PhilomenaMedia.Processors.Processor do
alias PhilomenaMedia.Processors alias PhilomenaMedia.Processors
alias PhilomenaMedia.Intensities 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()] @callback versions(Processors.version_list()) :: [Processors.version_filename()]
# Process the media at the given path against the given version list, and return an @doc """
# edit script with the resulting files 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() @callback process(Result.t(), Path.t(), Processors.version_list()) :: Processors.edit_script()
# Perform post-processing optimization tasks on the file, to reduce its size @doc """
# and strip non-essential metadata 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() @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() @callback intensities(Result.t(), Path.t()) :: Intensities.t()
end end

View file

@ -3,6 +3,7 @@ defmodule PhilomenaMedia.Processors.Webm do
alias PhilomenaMedia.Intensities alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.GifPreview
alias PhilomenaMedia.Processors.Processor alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors alias PhilomenaMedia.Processors
import Bitwise import Bitwise
@ -28,12 +29,11 @@ defmodule PhilomenaMedia.Processors.Webm do
duration = analysis.duration duration = analysis.duration
stripped = strip(file) stripped = strip(file)
preview = preview(duration, stripped) preview = preview(duration, stripped)
palette = gif_palette(stripped, duration)
mp4 = scale_mp4_only(stripped, dimensions, dimensions) mp4 = scale_mp4_only(stripped, dimensions, dimensions)
{:ok, intensities} = Intensities.file(preview) {: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"}] mp4 = [{:copy, mp4, "full.mp4"}]
[ [
@ -82,12 +82,12 @@ defmodule PhilomenaMedia.Processors.Webm do
stripped stripped
end 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) {webm, mp4} = scale_videos(file, dimensions, target_dimensions)
cond do cond do
thumb_name in [:thumb, :thumb_small, :thumb_tiny] -> 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"}, {:copy, webm, "#{thumb_name}.webm"},
@ -199,53 +199,14 @@ defmodule PhilomenaMedia.Processors.Webm do
mp4 mp4
end end
defp scale_gif(file, palette, duration, {width, height}) do defp scale_gif(file, duration, dimensions) do
gif = Briefly.create!(extname: ".gif") 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} = GifPreview.preview(file, gif, duration, dimensions)
System.cmd("ffmpeg", [
"-loglevel",
"0",
"-y",
"-i",
file,
"-i",
palette,
"-lavfi",
filter_graph,
"-r",
"2",
gif
])
gif gif
end 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 # x264 requires image dimensions to be a multiple of 2
# -2 = ~1 # -2 = ~1
def box_dimensions({width, height}, {target_width, target_height}) do def box_dimensions({width, height}, {target_width, target_height}) do
@ -255,8 +216,4 @@ defmodule PhilomenaMedia.Processors.Webm do
{new_width, new_height} {new_width, new_height}
end 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 end

View file

@ -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

View file

@ -17,9 +17,13 @@ defmodule PhilomenaProxy.Http do
@type url :: String.t() @type url :: String.t()
@type header_list :: [{String.t(), 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""" @doc ~S"""
Perform a HTTP GET request. Perform a HTTP GET request.
@ -27,15 +31,15 @@ defmodule PhilomenaProxy.Http do
## Example ## Example
iex> PhilomenaProxy.Http.get("http://example.com", [{"authorization", "Bearer #{token}"}]) 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") 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() @spec get(url(), header_list()) :: result()
def get(url, headers \\ [], options \\ []) do def get(url, headers \\ []) do
Tesla.get(client(headers), url, opts: [adapter: adapter_opts(options)]) request(:get, url, [], headers)
end end
@doc ~S""" @doc ~S"""
@ -44,15 +48,15 @@ defmodule PhilomenaProxy.Http do
## Example ## Example
iex> PhilomenaProxy.Http.head("http://example.com", [{"authorization", "Bearer #{token}"}]) 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") 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() @spec head(url(), header_list()) :: result()
def head(url, headers \\ [], options \\ []) do def head(url, headers \\ []) do
Tesla.head(client(headers), url, opts: [adapter: adapter_opts(options)]) request(:head, url, [], headers)
end end
@doc ~S""" @doc ~S"""
@ -61,27 +65,67 @@ defmodule PhilomenaProxy.Http do
## Example ## Example
iex> PhilomenaProxy.Http.post("http://example.com", "", [{"authorization", "Bearer #{token}"}]) 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", "") 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() @spec post(url(), body(), header_list()) :: result()
def post(url, body, headers \\ [], options \\ []) do def post(url, body, headers \\ []) do
Tesla.post(client(headers), url, body, opts: [adapter: adapter_opts(options)]) request(:post, url, body, headers)
end end
defp adapter_opts(opts) do @spec request(atom(), String.t(), iodata(), header_list()) :: result()
opts = Keyword.merge(opts, max_body: 125_000_000, inet6: true) 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
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]
]
]
_ ->
# 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 case Application.get_env(:philomena, :proxy_host) do
nil -> nil ->
opts []
url -> url ->
Keyword.merge(opts, proxy: proxy_opts(URI.parse(url))) [proxy: proxy_opts(URI.parse(url))]
end end
transport_opts ++ proxy_opts
end end
defp proxy_opts(%{host: host, port: port, scheme: "https"}), 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"}), defp proxy_opts(%{host: host, port: port, scheme: "http"}),
do: {:http, host, port, [transport_opts: [inet6: true]]} do: {:http, host, port, [transport_opts: [inet6: true]]}
defp client(headers) do defp stream_response_callback({:data, data}, {req, resp}) do
Tesla.client( req = update_in(req.private[@max_body_key], &(&1 + byte_size(data)))
[ resp = update_in(resp.body, &<<&1::binary, data::binary>>)
{Tesla.Middleware.FollowRedirects, max_redirects: 1},
{Tesla.Middleware.Headers, if req.private.resp_body_size < @max_body do
[ {:cont, {req, resp}}
{"User-Agent", else
"Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0"} {:halt, {req, RuntimeError.exception("body too big")}}
| headers end
]}
],
Tesla.Adapter.Mint
)
end end
end end

View file

@ -3,16 +3,16 @@ defmodule PhilomenaProxy.Scrapers do
Scrape utilities to facilitate uploading media from other websites. 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() @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 :: %{ @type image_result :: %{
url: url(), url: url(),
camo_url: url() camo_url: url()
} }
# Result of a successful scrape. @typedoc "Result of a successful scrape."
@type scrape_result :: %{ @type scrape_result :: %{
source_url: url(), source_url: url(),
description: String.t() | nil, description: String.t() | nil,
@ -56,16 +56,17 @@ defmodule PhilomenaProxy.Scrapers do
def scrape!(url) do def scrape!(url) do
uri = URI.parse(url) uri = URI.parse(url)
@scrapers cond do
|> Enum.find(& &1.can_handle?(uri, url)) is_nil(uri.host) ->
|> wrap() # Scraping without a hostname doesn't make sense because the proxy cannot fetch it, and
|> Enum.map(& &1.scrape(uri, url)) # some scrapers may test properties of the hostname.
|> unwrap() nil
end
defp wrap(nil), do: [] true ->
defp wrap(res), do: [res] # Find the first scraper which can handle the URL and process, or return nil
Enum.find_value(@scrapers, nil, fn scraper ->
defp unwrap([result]), do: result scraper.can_handle?(uri, url) && scraper.scrape(uri, url)
defp unwrap(_result), do: nil end)
end
end
end end

View file

@ -9,7 +9,6 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
@image_regex ~r|data-rh="true" rel="preload" href="([^"]*)" as="image"| @image_regex ~r|data-rh="true" rel="preload" href="([^"]*)" as="image"|
@source_regex ~r|rel="canonical" href="([^"]*)"| @source_regex ~r|rel="canonical" href="([^"]*)"|
@artist_regex ~r|https://www.deviantart.com/([^/]*)/art| @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/([^/]*)/([^/?]*)| @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)(.*)| @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.*)| @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() @spec scrape(URI.t(), Scrapers.url()) :: Scrapers.scrape_result()
def scrape(_uri, url) do def scrape(_uri, url) do
url url
|> follow_redirect(2) |> PhilomenaProxy.Http.get()
|> extract_data!() |> extract_data!()
|> try_intermediary_hires!() |> try_intermediary_hires!()
|> try_new_hires!() |> try_new_hires!()
|> try_old_hires!()
end 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) [image] = Regex.run(@image_regex, body, capture: :all_but_first)
[source] = Regex.run(@source_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) [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] <- with [domain, object_uuid, object_name] <-
Regex.run(@cdnint_regex, image.url, capture: :all_but_first), Regex.run(@cdnint_regex, image.url, capture: :all_but_first),
built_url <- "#{domain}/intermediary/f/#{object_uuid}/#{object_name}", 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. # This is the high resolution URL.
%{ %{
data data
@ -107,54 +105,4 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
data data
end end
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 end

View file

@ -24,7 +24,7 @@ defmodule PhilomenaProxy.Scrapers.Pillowfort do
|> process_response!(url) |> process_response!(url)
end end
defp json!({:ok, %Tesla.Env{body: body, status: 200}}), defp json!({:ok, %{body: body, status: 200}}),
do: Jason.decode!(body) do: Jason.decode!(body)
defp process_response!(post_json, url) do defp process_response!(post_json, url) do

View file

@ -10,14 +10,10 @@ defmodule PhilomenaProxy.Scrapers.Raw do
@spec can_handle?(URI.t(), String.t()) :: boolean() @spec can_handle?(URI.t(), String.t()) :: boolean()
def can_handle?(_uri, url) do def can_handle?(_uri, url) do
PhilomenaProxy.Http.head(url) with {:ok, %{status: 200, headers: headers}} <- PhilomenaProxy.Http.head(url),
|> case do [type | _] <- headers["content-type"] do
{:ok, %Tesla.Env{status: 200, headers: headers}} -> String.downcase(type) in @mime_types
headers else
|> Enum.any?(fn {k, v} ->
String.downcase(k) == "content-type" and String.downcase(v) in @mime_types
end)
_ -> _ ->
false false
end end

View file

@ -37,7 +37,7 @@ defmodule PhilomenaProxy.Scrapers.Tumblr do
|> process_response!() |> process_response!()
end end
defp json!({:ok, %Tesla.Env{body: body, status: 200}}), defp json!({:ok, %{body: body, status: 200}}),
do: Jason.decode!(body) do: Jason.decode!(body)
defp process_response!(%{"response" => %{"posts" => [post | _rest]}}), defp process_response!(%{"response" => %{"posts" => [post | _rest]}}),
@ -76,7 +76,7 @@ defmodule PhilomenaProxy.Scrapers.Tumblr do
end end
defp url_ok?(url) do defp url_ok?(url) do
match?({:ok, %Tesla.Env{status: 200}}, PhilomenaProxy.Http.head(url)) match?({:ok, %{status: 200}}, PhilomenaProxy.Http.head(url))
end end
defp add_meta(post, images) do defp add_meta(post, images) do

View file

@ -18,7 +18,7 @@ defmodule PhilomenaProxy.Scrapers.Twitter do
[user, status_id] = Regex.run(@url_regex, url, capture: :all_but_first) [user, status_id] = Regex.run(@url_regex, url, capture: :all_but_first)
api_url = "https://api.fxtwitter.com/#{user}/status/#{status_id}" 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) json = Jason.decode!(body)
tweet = json["tweet"] tweet = json["tweet"]

View file

@ -13,13 +13,31 @@ defmodule PhilomenaQuery.Batch do
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query 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 queryable :: any()
@type batch_size :: {:batch_size, integer()} @type batch_size :: {:batch_size, integer()}
@type id_field :: {:id_field, atom()} @type id_field :: {:id_field, atom()}
@type batch_options :: [batch_size() | id_field()] @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()) @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()) @type query_batch_callback :: ([Ecto.Query.t()] -> any())
@doc """ @doc """

View file

@ -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
<<ip::bits-size(mask), 0::integer-size(bit_size(ip) - mask)>>
end
defp list_to_bits(list, unit_length) do
for u <- list, into: <<>>, do: <<u::integer-size(unit_length)>>
end
defp bits_to_list(bits, unit_length) do
for <<u::integer-size(unit_length) <- bits>>, do: u
end
end

View file

@ -41,12 +41,34 @@ defmodule PhilomenaQuery.Parse.Parser do
TermRangeParser TermRangeParser
} }
@typedoc """
User-supplied context argument.
Provided to `parse/3` and passed to the transform callback.
"""
@type context :: any() @type context :: any()
@typedoc "Query in the search engine JSON query language."
@type query :: map() @type query :: map()
@typedoc "Whether the default field is `:term` (not analyzed) or `:ngram` (analyzed)."
@type default_field_type :: :term | :ngram @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()} @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 transform :: (context, String.t() -> transform_result())
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@ -112,11 +134,11 @@ defmodule PhilomenaQuery.Parse.Parser do
aliases: %{"hidden" => "hidden_from_users"} aliases: %{"hidden" => "hidden_from_users"}
] ]
Parser.parser(options) Parser.new(options)
""" """
@spec parser(keyword()) :: t() @spec new(keyword()) :: t()
def parser(options) do def new(options) do
parser = struct(Parser, options) parser = struct(Parser, options)
fields = fields =

View file

@ -18,10 +18,36 @@ defmodule PhilomenaQuery.Search do
# todo: fetch through compile_env? # todo: fetch through compile_env?
@policy Philomena.SearchPolicy @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() @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() @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() @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 :: %{ @type replacement :: %{
path: [String.t()], path: [String.t()],
old: term(), old: term(),

View file

@ -1,11 +1,34 @@
defmodule PhilomenaQuery.SearchIndex do defmodule PhilomenaQuery.SearchIndex do
# Returns the index name for the index. @moduledoc """
# This is usually a collection name like "images". 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() @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() @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() @callback as_json(struct()) :: map()
end end

View file

@ -7,7 +7,12 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do
import Ecto.Query import Ecto.Query
plug :verify_authorized 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] plug :check_can_delete when action in [:delete]
def index(conn, %{"q" => q}) when is_binary(q) do def index(conn, %{"q" => q}) when is_binary(q) do
@ -56,12 +61,12 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do
end end
def edit(conn, _params) do 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) render(conn, "edit.html", title: "Editing Fingerprint Ban", changeset: changeset)
end end
def update(conn, %{"fingerprint" => fingerprint_ban_params}) do 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} -> {:ok, fingerprint_ban} ->
conn conn
|> put_flash(:info, "Fingerprint ban successfully updated.") |> put_flash(:info, "Fingerprint ban successfully updated.")
@ -74,7 +79,7 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do
end end
def delete(conn, _params) do 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 conn
|> put_flash(:info, "Fingerprint ban successfully deleted.") |> put_flash(:info, "Fingerprint ban successfully deleted.")

View file

@ -44,7 +44,7 @@ defmodule PhilomenaWeb.Admin.SiteNoticeController do
case SiteNotices.update_site_notice(conn.assigns.site_notice, site_notice_params) do case SiteNotices.update_site_notice(conn.assigns.site_notice, site_notice_params) do
{:ok, _site_notice} -> {:ok, _site_notice} ->
conn conn
|> put_flash(:info, "Succesfully updated site notice.") |> put_flash(:info, "Successfully updated site notice.")
|> redirect(to: ~p"/admin/site_notices") |> redirect(to: ~p"/admin/site_notices")
{:error, changeset} -> {:error, changeset} ->
@ -56,7 +56,7 @@ defmodule PhilomenaWeb.Admin.SiteNoticeController do
{:ok, _site_notice} = SiteNotices.delete_site_notice(conn.assigns.site_notice) {:ok, _site_notice} = SiteNotices.delete_site_notice(conn.assigns.site_notice)
conn conn
|> put_flash(:info, "Sucessfully deleted site notice.") |> put_flash(:info, "Successfully deleted site notice.")
|> redirect(to: ~p"/admin/site_notices") |> redirect(to: ~p"/admin/site_notices")
end end

View file

@ -1,25 +1,27 @@
defmodule PhilomenaWeb.IpProfile.SourceChangeController do defmodule PhilomenaWeb.IpProfile.SourceChangeController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias PhilomenaQuery.IpMask
alias Philomena.SourceChanges.SourceChange alias Philomena.SourceChanges.SourceChange
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query
plug :verify_authorized 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) {:ok, ip} = EctoNetwork.INET.cast(ip)
range = IpMask.parse_mask(ip, params)
source_changes = source_changes =
SourceChange SourceChange
|> where(ip: ^ip) |> where(fragment("? >>= ip", ^range))
|> order_by(desc: :id) |> order_by(desc: :id)
|> preload([:user, image: [:user, :sources, tags: :aliases]]) |> preload([:user, image: [:user, :sources, tags: :aliases]])
|> Repo.paginate(conn.assigns.scrivener) |> Repo.paginate(conn.assigns.scrivener)
render(conn, "index.html", render(conn, "index.html",
title: "Source Changes for IP `#{ip}'", title: "Source Changes for IP `#{ip}'",
ip: ip, ip: range,
source_changes: source_changes source_changes: source_changes
) )
end end

View file

@ -1,6 +1,7 @@
defmodule PhilomenaWeb.IpProfile.TagChangeController do defmodule PhilomenaWeb.IpProfile.TagChangeController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias PhilomenaQuery.IpMask
alias Philomena.TagChanges.TagChange alias Philomena.TagChanges.TagChange
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query
@ -9,10 +10,11 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do
def index(conn, %{"ip_profile_id" => ip} = params) do def index(conn, %{"ip_profile_id" => ip} = params) do
{:ok, ip} = EctoNetwork.INET.cast(ip) {:ok, ip} = EctoNetwork.INET.cast(ip)
range = IpMask.parse_mask(ip, params)
tag_changes = tag_changes =
TagChange TagChange
|> where(ip: ^ip) |> where(fragment("? >>= ip", ^range))
|> added_filter(params) |> added_filter(params)
|> preload([:tag, :user, image: [:user, :sources, tags: :aliases]]) |> preload([:tag, :user, image: [:user, :sources, tags: :aliases]])
|> order_by(desc: :id) |> order_by(desc: :id)
@ -20,7 +22,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do
render(conn, "index.html", render(conn, "index.html",
title: "Tag Changes for IP `#{ip}'", title: "Tag Changes for IP `#{ip}'",
ip: ip, ip: range,
tag_changes: tag_changes tag_changes: tag_changes
) )
end end

View file

@ -212,7 +212,7 @@ defmodule PhilomenaWeb.ProfileController do
end end
defp individual_stat(mapping, stat_name) do 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 end
defp map_fetch(nil, _field_name), do: nil defp map_fetch(nil, _field_name), do: nil

View file

@ -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

View file

@ -36,7 +36,7 @@ defmodule PhilomenaWeb.CompromisedPasswordCheckPlug do
|> Base.encode16() |> Base.encode16()
case PhilomenaProxy.Http.get(make_api_url(prefix)) do 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 _ -> false
end end
end end

View file

@ -16,9 +16,7 @@ defmodule PhilomenaWeb.CurrentBanPlug do
@doc false @doc false
@spec call(Conn.t(), any()) :: Conn.t() @spec call(Conn.t(), any()) :: Conn.t()
def call(conn, _opts) do def call(conn, _opts) do
conn = Conn.fetch_cookies(conn) fingerprint = conn.assigns.fingerprint
fingerprint = conn.cookies["_ses"]
user = conn.assigns.current_user user = conn.assigns.current_user
ip = conn.remote_ip ip = conn.remote_ip

View file

@ -37,9 +37,7 @@ defmodule PhilomenaWeb.FilterBannedUsersPlug do
defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn
defp maybe_halt_no_fingerprint(conn) do defp maybe_halt_no_fingerprint(conn) do
conn = Conn.fetch_cookies(conn) case conn.assigns.fingerprint do
case conn.cookies["_ses"] do
nil -> nil ->
PhilomenaWeb.NotAuthorizedPlug.call(conn) PhilomenaWeb.NotAuthorizedPlug.call(conn)

View file

@ -1,10 +1,12 @@
defmodule PhilomenaWeb.ScraperPlug do defmodule PhilomenaWeb.ScraperPlug do
@filename_regex ~r/filename="([^"]+)"/ @filename_regex ~r/filename="([^"]+)"/
@spec init(keyword()) :: keyword()
def init(opts) do def init(opts) do
opts opts
end end
@spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def call(conn, opts) do def call(conn, opts) do
params_name = Keyword.get(opts, :params_name, "image") params_name = Keyword.get(opts, :params_name, "image")
params_key = Keyword.get(opts, :params_key, "image") params_key = Keyword.get(opts, :params_key, "image")
@ -25,18 +27,13 @@ defmodule PhilomenaWeb.ScraperPlug do
# Writing the tempfile doesn't allow traversal # Writing the tempfile doesn't allow traversal
# sobelow_skip ["Traversal.FileModule"] # sobelow_skip ["Traversal.FileModule"]
defp maybe_fixup_params( defp maybe_fixup_params({:ok, %{status: 200} = resp}, url, opts, conn) do
{:ok, %Tesla.Env{body: body, status: 200, headers: headers}},
url,
opts,
conn
) do
params_name = Keyword.get(opts, :params_name, "image") params_name = Keyword.get(opts, :params_name, "image")
params_key = Keyword.get(opts, :params_key, "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 = Plug.Upload.random_file!(UUID.uuid1())
File.write!(file, body) File.write!(file, resp.body)
fake_upload = %Plug.Upload{ fake_upload = %Plug.Upload{
path: file, path: file,
@ -44,22 +41,20 @@ defmodule PhilomenaWeb.ScraperPlug do
filename: name filename: name
} }
updated_form = Map.put(conn.params[params_name], params_key, fake_upload) put_in(conn.params[params_name][params_key], fake_upload)
updated_params = Map.put(conn.params, params_name, updated_form)
%Plug.Conn{conn | params: updated_params}
end end
defp maybe_fixup_params(_response, _url, _opts, conn), do: conn defp maybe_fixup_params(_response, _url, _opts, conn), do: conn
defp extract_filename(url, resp_headers) do defp extract_filename(url, headers) do
{_, header} = name =
Enum.find(resp_headers, {nil, "filename=\"#{Path.basename(url)}\""}, fn {key, value} -> with [value | _] <- headers["content-disposition"],
key == "content-disposition" and Regex.match?(@filename_regex, value) [name] <- Regex.run(@filename_regex, value, capture: :all_but_first) do
end) name
else
[name] = Regex.run(@filename_regex, header, capture: :all_but_first) _ ->
Path.basename(url)
end
String.slice(name, 0, 127) String.slice(name, 0, 127)
end end

View file

@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Router do
use PhilomenaWeb, :router use PhilomenaWeb, :router
import PhilomenaWeb.UserAuth import PhilomenaWeb.UserAuth
import PhilomenaWeb.Fingerprint
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
@ -9,6 +10,7 @@ defmodule PhilomenaWeb.Router do
plug :fetch_flash plug :fetch_flash
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :fetch_fingerprint
plug :fetch_current_user plug :fetch_current_user
plug PhilomenaWeb.ContentSecurityPolicyPlug plug PhilomenaWeb.ContentSecurityPolicyPlug
plug PhilomenaWeb.CurrentFilterPlug plug PhilomenaWeb.CurrentFilterPlug

View file

@ -1,6 +1,6 @@
h1 Editing ban 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 br
= link "Back", to: ~p"/admin/fingerprint_bans" = link "Back", to: ~p"/admin/fingerprint_bans"

View file

@ -59,5 +59,5 @@ h1 = @conversation.title
p You've managed to send over 1,000 messages in this conversation! 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 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 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. ' to make a new conversation with this user.

View file

@ -73,7 +73,7 @@
.fieldlabel .fieldlabel
strong You probably do not want to check this unless you know what you are doing - it cannot be changed later 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 -> - input_value(f, :public) == true ->
.fieldlabel .fieldlabel

View file

@ -8,7 +8,7 @@
p p
'The page(s) you found this image on. Images may have a maximum of '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. ' 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 -> = inputs_for f, :sources, [as: "image[old_sources]", skip_hidden: true], fn fs ->

View file

@ -40,7 +40,7 @@
h4 About this image h4 About this image
p p
'The page(s) you found this image on. Images may have a maximum of '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. ' source URLs. Leave any sources you don't want to use blank.
= inputs_for f, :sources, fn fs -> = inputs_for f, :sources, fn fs ->

View file

@ -11,8 +11,17 @@ ul
h2 Administration Options h2 Administration Options
ul ul
li = link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" li
li = link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" => link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes"
= if ipv6?(@ip) do
' &hellip;
= 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
' &hellip;
= 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 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 "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)]}" li = link "Ban this sucker", to: ~p"/admin/subnet_bans/new?#{[specification: to_string(@ip)]}"

View file

@ -11,6 +11,7 @@ html lang="en"
=< site_name() =< site_name()
- else - else
=<> site_name() =<> site_name()
link rel="preconnect" href="https://#{cdn_host()}"
link rel="stylesheet" href=~p"/css/application.css" link rel="stylesheet" href=~p"/css/application.css"
link rel="stylesheet" href=stylesheet_path(@conn, @current_user) link rel="stylesheet" href=stylesheet_path(@conn, @current_user)
= if is_nil(@current_user) do = if is_nil(@current_user) do

View file

@ -1,6 +1,6 @@
- form = assigns[:f] - form = assigns[:f]
- action_text = assigns[:action_text] || 'Edit' - action_text = assigns[:action_text] || "Edit"
- action_icon = assigns[:action_icon] || 'edit' - action_icon = assigns[:action_icon] || "edit"
- field_name = assigns[:name] || :body - field_name = assigns[:name] || :body
- field_placeholder = assigns[:placeholder] || "Your message" - field_placeholder = assigns[:placeholder] || "Your message"
- is_required = assigns[:required] - is_required = assigns[:required]
@ -11,7 +11,7 @@
= action_text = action_text
a.button href="#" data-click-tab="preview" a.button href="#" data-click-tab="preview"
i.fa.fa-cog.fa-spin.js-preview-loading.hidden> title=raw('Loading preview&hellip;') i.fa.fa-cog.fa-spin.js-preview-loading.hidden> title=raw("Loading preview&hellip;")
i.fa.fa-eye.js-preview-idle> i.fa.fa-eye.js-preview-idle>
| Preview | Preview

View file

@ -23,7 +23,7 @@
.field .field
=> label f, :categories, "Art Categories:" => label f, :categories, "Art Categories:"
br 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 = error_tag f, :categories
.field .field
=> label f, :sheet_image_id, "Image ID of your commissions sheet (optional but recommended):" => label f, :sheet_image_id, "Image ID of your commissions sheet (optional but recommended):"

View file

@ -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}" 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
span.tag__state.hidden title="Unwatched" span.tag__state.hidden title="Unwatched"
| + | +

View file

@ -29,10 +29,10 @@ p.fieldlabel
= select @f, :vote_method, ["-": "", "Single option": :single, "Multiple options": :multiple], class: "input" = select @f, :vote_method, ["-": "", "Single option": :single, "Multiple options": :multiple], class: "input"
= error_tag @f, :vote_method = 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 .field.js-poll-option.field--inline.flex--no-wrap.flex--centered
= text_input fo, :label, class: "input flex__grow js-option-label", placeholder: "Option" = text_input opt, :label, class: "input flex__grow js-option-label", placeholder: "Option"
= error_tag fo, :label = error_tag opt, :label
label.input--separate-left.flex__fixed.flex--centered label.input--separate-left.flex__fixed.flex--centered
a.js-option-remove href="#" a.js-option-remove href="#"

View file

@ -211,9 +211,8 @@ defmodule PhilomenaWeb.UserAuth do
defp update_usages(conn, user) do defp update_usages(conn, user) do
now = DateTime.utc_now() |> DateTime.truncate(:second) now = DateTime.utc_now() |> DateTime.truncate(:second)
conn = fetch_cookies(conn)
UserIpUpdater.cast(user.id, conn.remote_ip, now) 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
end end

View file

@ -3,6 +3,8 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query
alias PhilomenaWeb.Fingerprint
def child_spec([]) do def child_spec([]) do
%{ %{
id: PhilomenaWeb.UserFingerprintUpdater, id: PhilomenaWeb.UserFingerprintUpdater,
@ -14,13 +16,12 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do
{:ok, spawn_link(&init/0)} {:ok, spawn_link(&init/0)}
end end
def cast(user_id, <<"c", _rest::binary>> = fingerprint, updated_at) def cast(user_id, fingerprint, updated_at) do
when byte_size(fingerprint) <= 12 do if Fingerprint.valid_format?(fingerprint) do
pid = Process.whereis(:fingerprint_updater) pid = Process.whereis(:fingerprint_updater)
if pid, do: send(pid, {user_id, fingerprint, updated_at}) if pid, do: send(pid, {user_id, fingerprint, updated_at})
end end
end
def cast(_user_id, _fingerprint, _updated_at), do: nil
defp init do defp init do
Process.register(self(), :fingerprint_updater) Process.register(self(), :fingerprint_updater)

View file

@ -1,3 +1,8 @@
defmodule PhilomenaWeb.IpProfileView do defmodule PhilomenaWeb.IpProfileView do
use PhilomenaWeb, :view use PhilomenaWeb, :view
@spec ipv6?(Postgrex.INET.t()) :: boolean()
def ipv6?(ip) do
tuple_size(ip.address) == 8
end
end end

22
mix.exs
View file

@ -11,7 +11,8 @@ defmodule Philomena.MixProject do
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
dialyzer: [plt_add_apps: [:mix]] dialyzer: [plt_add_apps: [:mix]],
docs: [formatters: ["html"]]
] ]
end end
@ -34,7 +35,7 @@ defmodule Philomena.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:phoenix, "~> 1.6"}, {:phoenix, "~> 1.7"},
{:phoenix_pubsub, "~> 2.1"}, {:phoenix_pubsub, "~> 2.1"},
{:phoenix_ecto, "~> 4.4"}, {:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.9"}, {:ecto_sql, "~> 3.9"},
@ -44,10 +45,8 @@ defmodule Philomena.MixProject do
{:phoenix_live_reload, "~> 1.4", only: :dev}, {:phoenix_live_reload, "~> 1.4", only: :dev},
{:gettext, "~> 0.22"}, {:gettext, "~> 0.22"},
{:jason, "~> 1.4"}, {:jason, "~> 1.4"},
{:ranch, "~> 2.1", override: true}, {:bandit, "~> 1.2"},
{:plug_cowboy, "~> 2.6"}, {:slime, "~> 1.3.1"},
{:slime, "~> 1.3.0",
github: "liamwhite/slime", ref: "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31", override: true},
{:phoenix_slime, "~> 0.13", {:phoenix_slime, "~> 0.13",
github: "slime-lang/phoenix_slime", ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"}, github: "slime-lang/phoenix_slime", ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"},
{:phoenix_pubsub_redis, "~> 3.0"}, {:phoenix_pubsub_redis, "~> 3.0"},
@ -64,9 +63,7 @@ defmodule Philomena.MixProject do
{:redix, "~> 1.2"}, {:redix, "~> 1.2"},
{:remote_ip, "~> 1.1"}, {:remote_ip, "~> 1.1"},
{:briefly, "~> 0.4"}, {:briefly, "~> 0.4"},
{:tesla, "~> 1.5"}, {:req, "~> 0.5"},
{:castore, "~> 1.0", override: true},
{:mint, "~> 1.4"},
{:exq, "~> 0.17"}, {:exq, "~> 0.17"},
{:ex_aws, "~> 2.0", {:ex_aws, "~> 2.0",
github: "liamwhite/ex_aws", ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4", override: true}, github: "liamwhite/ex_aws", ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4", override: true},
@ -83,9 +80,10 @@ defmodule Philomena.MixProject do
{:rustler, "~> 0.27"}, {:rustler, "~> 0.27"},
# Linting # 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_envvar, "~> 0.1", only: [:dev, :test], runtime: false},
{:credo_naming, "~> 2.0", only: [:dev, :test], runtime: false}, {:credo_naming, "~> 2.0", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.30", only: [:dev], runtime: false},
# Security checks # Security checks
{:sobelow, "~> 0.11", only: [:dev, :test], runtime: true}, {:sobelow, "~> 0.11", only: [:dev, :test], runtime: true},
@ -94,10 +92,6 @@ defmodule Philomena.MixProject do
# Static analysis # Static analysis
{:dialyxir, "~> 1.2", only: :dev, runtime: false}, {: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+ # Fixes for Elixir v1.15+
{:canary, "~> 1.1", {:canary, "~> 1.1",
github: "marcinkoziej/canary", ref: "704debde7a2c0600f78c687807884bf37c45bd79"} github: "marcinkoziej/canary", ref: "704debde7a2c0600f78c687807884bf37c45bd79"}

View file

@ -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"}, "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"}, "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"}, "canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"},
"canary": {:git, "https://github.com/marcinkoziej/canary.git", "704debde7a2c0600f78c687807884bf37c45bd79", [ref: "704debde7a2c0600f78c687807884bf37c45bd79"]}, "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"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, "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"}, "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"},
"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_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, "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"}, "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"}, "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"}, "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"}, "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_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"}, "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"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "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": {: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_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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"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"}, "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.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"}, "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.1", "7f1c20dbe7266d514a07bf5b7a3946413d70150be41cb5475b5a95bb517a378f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0bc556803a1d09dfb69bfebecb838cf33a2d123de84f700c41b6b8134027c11f"}, "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"}, "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_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"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]}, "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": {: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.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_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.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, "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.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_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": {: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_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_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_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"}, "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": {: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_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.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "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"}, "pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"},
"qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"}, "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.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"},
"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.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"},
"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"}, "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"}, "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": {: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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "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": {: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"}, "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"}, "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
} }

View file

@ -9,6 +9,7 @@ defmodule PhilomenaWeb.UserAuthTest do
conn = conn =
conn conn
|> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base)) |> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base))
|> assign(:fingerprint, "d015c342859dde3")
|> init_test_session(%{}) |> init_test_session(%{})
%{user: user_fixture(), conn: conn} %{user: user_fixture(), conn: conn}

View file

@ -41,9 +41,11 @@ defmodule PhilomenaWeb.ConnCase do
|> Philomena.Filters.change_filter() |> Philomena.Filters.change_filter()
|> Philomena.Repo.insert!() |> Philomena.Repo.insert!()
fingerprint = to_string(:io_lib.format(~c"d~14.16.0b", [:rand.uniform(2 ** 53)]))
conn = conn =
Phoenix.ConnTest.build_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} {:ok, conn: conn}
end end