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:
path: |
_build
.cargo
deps
key: ${{ runner.os }}-build-deps-${{ hashFiles('mix.lock') }}
key: ${{ runner.os }}-deps-2-${{ hashFiles('mix.lock') }}
- name: Enable caching
run: |
# Disable volumes so caching can take effect
sed -i -Ee 's/- app_[a-z]+_data:.*$//g' docker-compose.yml
# Make ourselves the owner
echo "RUN addgroup -g $(id -g) -S appgroup && adduser -u $(id -u) -S appuser -G appgroup" >> docker/app/Dockerfile
echo "USER appuser" >> docker/app/Dockerfile
echo "RUN mix local.hex --force && mix local.rebar --force" >> docker/app/Dockerfile
- run: docker compose pull
- run: docker compose build
@ -27,6 +38,18 @@ jobs:
run: |
docker compose run app mix sobelow --config
docker compose run app mix deps.audit
- name: Dialyzer
run: |
docker compose run app mix dialyzer
typos:
name: 'Check for spelling errors'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crate-ci/typos@master
lint-and-test:
name: 'JavaScript Linting and Unit Tests'
runs-on: ubuntu-latest

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 { clearEl, makeEl } from './utils/dom';
function insertCaptcha(_event: Event, target: HTMLInputElement) {
const { parentElement, dataset: { sitekey } } = target;
if (!parentElement) { return; }
const parentElement = assertNotNull(target.parentElement);
const script = makeEl('script', {src: 'https://hcaptcha.com/1/api.js', async: true, defer: true});
const frame = makeEl('div', {className: 'h-captcha'});
frame.dataset.sitekey = sitekey;
frame.dataset.sitekey = target.dataset.sitekey;
clearEl(parentElement);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
import { $$, showEl, hideEl } from './utils/dom';
import { assertNotUndefined } from './utils/assert';
import { TagSourceEvent } from './sources';
import '../types/ujs';
type TagDropdownActionFunction = () => void;
type TagDropdownActionList = Record<string, TagDropdownActionFunction>;
@ -19,8 +19,8 @@ function removeTag(tagId: number, list: number[]) {
function createTagDropdown(tag: HTMLSpanElement) {
const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link');
const [ unwatched, watched, spoilered, hidden ] = $$<HTMLSpanElement>('.tag__state');
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link', tag);
const [ unwatched, watched, spoilered, hidden ] = $$<HTMLSpanElement>('.tag__state', tag);
const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10);
const actions: TagDropdownActionList = {
@ -56,15 +56,14 @@ function createTagDropdown(tag: HTMLSpanElement) {
if (userIsSignedIn &&
!userCanEditFilter) showEl(filter);
tag.addEventListener('fetchcomplete', ((event: TagSourceEvent) => {
const act = event.target.dataset.tagAction;
if (act && actions[act]) {
tag.addEventListener('fetchcomplete', event => {
const act = assertNotUndefined(event.target.dataset.tagAction);
actions[act]();
}
}) as EventListener);
});
}
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
*/
import { assertType, assertNotNull } from './utils/assert';
import { $, $$ } from './utils/dom';
import store from './utils/store';
import { initTagDropdown } from './tags';
import { setupTagsInput, reloadTagsInput } from './tagsinput';
import { TagSourceEvent } from './sources';
import '../types/ujs';
type TagInputActionFunction = (tagInput: HTMLTextAreaElement | null) => void
type TagInputActionList = {
save: TagInputActionFunction,
load: TagInputActionFunction,
clear: TagInputActionFunction,
}
type TagInputActionFunction = (tagInput: HTMLTextAreaElement) => void;
type TagInputActionList = Record<string, TagInputActionFunction>;
function tagInputButtons(event: MouseEvent) {
const target = assertType(event.target, HTMLElement);
function tagInputButtons({target}: PointerEvent) {
const actions: TagInputActionList = {
save(tagInput: HTMLTextAreaElement | null) {
if (tagInput) store.set('tag_input', tagInput.value);
save(tagInput: HTMLTextAreaElement) {
store.set('tag_input', tagInput.value);
},
load(tagInput: HTMLTextAreaElement | null) {
if (!tagInput) { return; }
load(tagInput: HTMLTextAreaElement) {
// If entry 'tag_input' does not exist, try to use the current list
tagInput.value = store.get('tag_input') || tagInput.value;
reloadTagsInput(tagInput);
},
clear(tagInput: HTMLTextAreaElement | null) {
if (!tagInput) { return; }
clear(tagInput: HTMLTextAreaElement) {
tagInput.value = '';
reloadTagsInput(tagInput);
},
};
for (const action in actions) {
if (target && (target as HTMLElement).matches(`#tagsinput-${action}`)) {
actions[action as keyof TagInputActionList]($<HTMLTextAreaElement>('image_tag_input'));
for (const [ name, action ] of Object.entries(actions)) {
if (target && target.matches(`#tagsinput-${name}`)) {
action(assertNotNull($<HTMLTextAreaElement>('#image_tag_input')));
}
}
}
@ -49,10 +44,10 @@ function setupTags() {
});
}
function updateTagSauce({target, detail}: TagSourceEvent) {
const tagSauce = $<HTMLDivElement>('.js-tagsauce');
function updateTagSauce({ target, detail }: FetchcompleteEvent) {
if (target.matches('#tags-form')) {
const tagSauce = assertNotNull($<HTMLDivElement>('.js-tagsauce'));
if (tagSauce && target.matches('#tags-form')) {
detail.text().then(text => {
tagSauce.outerHTML = text;
setupTags();
@ -63,8 +58,8 @@ function updateTagSauce({target, detail}: TagSourceEvent) {
function setupTagEvents() {
setupTags();
document.addEventListener('fetchcomplete', updateTagSauce as EventListener);
document.addEventListener('click', tagInputButtons as EventListener);
document.addEventListener('fetchcomplete', updateTagSauce);
document.addEventListener('click', tagInputButtons);
}
export { setupTagEvents };

View file

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

361
assets/package-lock.json generated
View file

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

View file

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

View file

@ -72,7 +72,7 @@ interface BooruObject {
/**
* List of image IDs in the current gallery.
*/
galleryImages: number[]
galleryImages?: number[];
}
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
config :philomena, PhilomenaWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [host: "localhost"],
secret_key_base: "xZYTon09JNRrj8snd7KL31wya4x71jmo5aaSSRmw1dGjWLRmEwWMTccwxgsGFGjM",
render_errors: [view: PhilomenaWeb.ErrorView, accepts: ~w(html json)],
@ -46,8 +47,6 @@ config :phoenix, :template_engines,
slime: PhoenixSlime.Engine,
slimleex: PhoenixSlime.LiveViewEngine
config :tesla, adapter: Tesla.Adapter.Mint
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",

View file

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

View file

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

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

View file

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

View file

@ -12,7 +12,7 @@ defmodule Philomena.ArtistLinks.AutomaticVerifier do
end
end
defp contains_verification_code?({:ok, %Tesla.Env{body: body, status: 200}}, code) do
defp contains_verification_code?({:ok, %{body: body, status: 200}}, code) do
String.contains?(body, code)
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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.
You can unlock your account by visting the URL below:
You can unlock your account by visiting the URL below:
#{url}

View file

@ -54,7 +54,8 @@ defmodule PhilomenaMedia.Analyzers do
:error = Analyzers.analyze(file)
"""
@spec analyze(Plug.Upload.t() | Path.t()) :: {:ok, Result.t()} | :error
@spec analyze(Plug.Upload.t() | Path.t()) ::
{:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error
def analyze(%Plug.Upload{path: path}), do: analyze(path)
def analyze(path) when is_binary(path) do

View file

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

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}
>
> Clients should prefer to use `m:PhilomenaMedia.Processors.intensities/2`, as it handles
> Clients should prefer to use `PhilomenaMedia.Processors.intensities/2`, as it handles
> media files of any type supported by this library, not just PNG or JPEG.
## Examples

View file

@ -62,29 +62,31 @@ defmodule PhilomenaMedia.Processors do
alias PhilomenaMedia.Processors.{Gif, Jpeg, Png, Svg, Webm}
alias PhilomenaMedia.Mime
# The name of a version, like :large
@typedoc "The name of a version, like `:large`."
@type version_name :: atom()
@type dimensions :: {integer(), integer()}
@type version_list :: [{version_name(), dimensions()}]
# The file name of a processed version, like "large.png"
@typedoc "The file name of a processed version, like `large.png`."
@type version_filename :: String.t()
# A single file to be copied to satisfy a request for a version name
@typedoc "A single file to be copied to satisfy a request for a version name."
@type copy_request :: {:copy, Path.t(), version_filename()}
# A list of thumbnail versions to copy into place
@typedoc "A list of thumbnail versions to copy into place."
@type thumbnails :: {:thumbnails, [copy_request()]}
# Replace the original file to strip metadata or losslessly optimize
@typedoc "Replace the original file to strip metadata or losslessly optimize."
@type replace_original :: {:replace_original, Path.t()}
# Apply the computed corner intensities
@typedoc "Apply the computed corner intensities."
@type intensities :: {:intensities, Intensities.t()}
# An edit script, representing the changes to apply to the storage backend
# after successful processing
@typedoc """
An edit script, representing the changes to apply to the storage backend
after successful processing.
"""
@type edit_script :: [thumbnails() | replace_original() | intensities()]
@doc """

View file

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

View file

@ -3,6 +3,7 @@ defmodule PhilomenaMedia.Processors.Webm do
alias PhilomenaMedia.Intensities
alias PhilomenaMedia.Analyzers.Result
alias PhilomenaMedia.GifPreview
alias PhilomenaMedia.Processors.Processor
alias PhilomenaMedia.Processors
import Bitwise
@ -28,12 +29,11 @@ defmodule PhilomenaMedia.Processors.Webm do
duration = analysis.duration
stripped = strip(file)
preview = preview(duration, stripped)
palette = gif_palette(stripped, duration)
mp4 = scale_mp4_only(stripped, dimensions, dimensions)
{:ok, intensities} = Intensities.file(preview)
scaled = Enum.flat_map(versions, &scale(stripped, palette, duration, dimensions, &1))
scaled = Enum.flat_map(versions, &scale(stripped, duration, dimensions, &1))
mp4 = [{:copy, mp4, "full.mp4"}]
[
@ -82,12 +82,12 @@ defmodule PhilomenaMedia.Processors.Webm do
stripped
end
defp scale(file, palette, duration, dimensions, {thumb_name, target_dimensions}) do
defp scale(file, duration, dimensions, {thumb_name, target_dimensions}) do
{webm, mp4} = scale_videos(file, dimensions, target_dimensions)
cond do
thumb_name in [:thumb, :thumb_small, :thumb_tiny] ->
gif = scale_gif(file, palette, duration, target_dimensions)
gif = scale_gif(file, duration, target_dimensions)
[
{:copy, webm, "#{thumb_name}.webm"},
@ -199,53 +199,14 @@ defmodule PhilomenaMedia.Processors.Webm do
mp4
end
defp scale_gif(file, palette, duration, {width, height}) do
defp scale_gif(file, duration, dimensions) do
gif = Briefly.create!(extname: ".gif")
scale_filter = "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease"
palette_filter = "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
rate_filter = rate_filter(duration)
filter_graph = "[0:v]#{scale_filter},#{rate_filter}[x];[x][1:v]#{palette_filter}"
{_output, 0} =
System.cmd("ffmpeg", [
"-loglevel",
"0",
"-y",
"-i",
file,
"-i",
palette,
"-lavfi",
filter_graph,
"-r",
"2",
gif
])
GifPreview.preview(file, gif, duration, dimensions)
gif
end
defp gif_palette(file, duration) do
palette = Briefly.create!(extname: ".png")
palette_filter = "palettegen=stats_mode=diff"
rate_filter = rate_filter(duration)
filter_graph = "#{rate_filter},#{palette_filter}"
{_output, 0} =
System.cmd("ffmpeg", [
"-loglevel",
"0",
"-y",
"-i",
file,
"-vf",
filter_graph,
palette
])
palette
end
# x264 requires image dimensions to be a multiple of 2
# -2 = ~1
def box_dimensions({width, height}, {target_width, target_height}) do
@ -255,8 +216,4 @@ defmodule PhilomenaMedia.Processors.Webm do
{new_width, new_height}
end
# Avoid division by zero
def rate_filter(duration) when duration > 0.5, do: "fps=1/#{duration / 10},settb=1/2,setpts=N"
def rate_filter(_duration), do: "setpts=N/TB/2"
end

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 header_list :: [{String.t(), String.t()}]
@type body :: binary()
@type body :: iodata()
@type result :: {:ok, Req.Response.t()} | {:error, Exception.t()}
@type client_options :: keyword()
@user_agent "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0"
@max_body 125_000_000
@max_body_key :resp_body_size
@doc ~S"""
Perform a HTTP GET request.
@ -27,15 +31,15 @@ defmodule PhilomenaProxy.Http do
## Example
iex> PhilomenaProxy.Http.get("http://example.com", [{"authorization", "Bearer #{token}"}])
{:ok, %Tesla.Env{...}}
{:ok, %{status: 200, body: ...}}
iex> PhilomenaProxy.Http.get("http://nonexistent.example.com")
{:error, %Mint.TransportError{reason: :nxdomain}}
{:error, %Req.TransportError{reason: :nxdomain}}
"""
@spec get(url(), header_list(), client_options()) :: Tesla.Env.result()
def get(url, headers \\ [], options \\ []) do
Tesla.get(client(headers), url, opts: [adapter: adapter_opts(options)])
@spec get(url(), header_list()) :: result()
def get(url, headers \\ []) do
request(:get, url, [], headers)
end
@doc ~S"""
@ -44,15 +48,15 @@ defmodule PhilomenaProxy.Http do
## Example
iex> PhilomenaProxy.Http.head("http://example.com", [{"authorization", "Bearer #{token}"}])
{:ok, %Tesla.Env{...}}
{:ok, %{status: 200, body: ...}}
iex> PhilomenaProxy.Http.head("http://nonexistent.example.com")
{:error, %Mint.TransportError{reason: :nxdomain}}
{:error, %Req.TransportError{reason: :nxdomain}}
"""
@spec head(url(), header_list(), client_options()) :: Tesla.Env.result()
def head(url, headers \\ [], options \\ []) do
Tesla.head(client(headers), url, opts: [adapter: adapter_opts(options)])
@spec head(url(), header_list()) :: result()
def head(url, headers \\ []) do
request(:head, url, [], headers)
end
@doc ~S"""
@ -61,27 +65,67 @@ defmodule PhilomenaProxy.Http do
## Example
iex> PhilomenaProxy.Http.post("http://example.com", "", [{"authorization", "Bearer #{token}"}])
{:ok, %Tesla.Env{...}}
{:ok, %{status: 200, body: ...}}
iex> PhilomenaProxy.Http.post("http://nonexistent.example.com", "")
{:error, %Mint.TransportError{reason: :nxdomain}}
{:error, %Req.TransportError{reason: :nxdomain}}
"""
@spec post(url(), body(), header_list(), client_options()) :: Tesla.Env.result()
def post(url, body, headers \\ [], options \\ []) do
Tesla.post(client(headers), url, body, opts: [adapter: adapter_opts(options)])
@spec post(url(), body(), header_list()) :: result()
def post(url, body, headers \\ []) do
request(:post, url, body, headers)
end
defp adapter_opts(opts) do
opts = Keyword.merge(opts, max_body: 125_000_000, inet6: true)
@spec request(atom(), String.t(), iodata(), header_list()) :: result()
defp request(method, url, body, headers) do
Req.new(
method: method,
url: url,
body: body,
headers: [{:user_agent, @user_agent} | headers],
max_redirects: 1,
connect_options: connect_options(url),
inet6: true,
into: &stream_response_callback/2,
decode_body: false
)
|> Req.Request.put_private(@max_body_key, 0)
|> Req.request()
end
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
nil ->
opts
[]
url ->
Keyword.merge(opts, proxy: proxy_opts(URI.parse(url)))
[proxy: proxy_opts(URI.parse(url))]
end
transport_opts ++ proxy_opts
end
defp proxy_opts(%{host: host, port: port, scheme: "https"}),
@ -90,18 +134,14 @@ defmodule PhilomenaProxy.Http do
defp proxy_opts(%{host: host, port: port, scheme: "http"}),
do: {:http, host, port, [transport_opts: [inet6: true]]}
defp client(headers) do
Tesla.client(
[
{Tesla.Middleware.FollowRedirects, max_redirects: 1},
{Tesla.Middleware.Headers,
[
{"User-Agent",
"Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0"}
| headers
]}
],
Tesla.Adapter.Mint
)
defp stream_response_callback({:data, data}, {req, resp}) do
req = update_in(req.private[@max_body_key], &(&1 + byte_size(data)))
resp = update_in(resp.body, &<<&1::binary, data::binary>>)
if req.private.resp_body_size < @max_body do
{:cont, {req, resp}}
else
{:halt, {req, RuntimeError.exception("body too big")}}
end
end
end

View file

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

View file

@ -9,7 +9,6 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
@image_regex ~r|data-rh="true" rel="preload" href="([^"]*)" as="image"|
@source_regex ~r|rel="canonical" href="([^"]*)"|
@artist_regex ~r|https://www.deviantart.com/([^/]*)/art|
@serial_regex ~r|https://www.deviantart.com/(?:.*?)-(\d+)\z|
@cdnint_regex ~r|(https://images-wixmp-[0-9a-f]+.wixmp.com)(?:/intermediary)?/f/([^/]*)/([^/?]*)|
@png_regex ~r|(https://[0-9a-z\-\.]+(?:/intermediary)?/f/[0-9a-f\-]+/[0-9a-z\-]+\.png/v1/fill/[0-9a-z_,]+/[0-9a-z_\-]+)(\.png)(.*)|
@jpg_regex ~r|(https://[0-9a-z\-\.]+(?:/intermediary)?/f/[0-9a-f\-]+/[0-9a-z\-]+\.jpg/v1/fill/w_[0-9]+,h_[0-9]+,q_)([0-9]+)(,[a-z]+\/[a-z0-6_\-]+\.jpe?g.*)|
@ -31,14 +30,13 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
@spec scrape(URI.t(), Scrapers.url()) :: Scrapers.scrape_result()
def scrape(_uri, url) do
url
|> follow_redirect(2)
|> PhilomenaProxy.Http.get()
|> extract_data!()
|> try_intermediary_hires!()
|> try_new_hires!()
|> try_old_hires!()
end
defp extract_data!({:ok, %Tesla.Env{body: body, status: 200}}) do
defp extract_data!({:ok, %{body: body, status: 200}}) do
[image] = Regex.run(@image_regex, body, capture: :all_but_first)
[source] = Regex.run(@source_regex, body, capture: :all_but_first)
[artist] = Regex.run(@artist_regex, source, capture: :all_but_first)
@ -60,7 +58,7 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
with [domain, object_uuid, object_name] <-
Regex.run(@cdnint_regex, image.url, capture: :all_but_first),
built_url <- "#{domain}/intermediary/f/#{object_uuid}/#{object_name}",
{:ok, %Tesla.Env{status: 200}} <- PhilomenaProxy.Http.head(built_url) do
{:ok, %{status: 200}} <- PhilomenaProxy.Http.head(built_url) do
# This is the high resolution URL.
%{
data
@ -107,54 +105,4 @@ defmodule PhilomenaProxy.Scrapers.Deviantart do
data
end
end
defp try_old_hires!(%{source_url: source, images: [image]} = data) do
[serial] = Regex.run(@serial_regex, source, capture: :all_but_first)
base36 =
serial
|> String.to_integer()
|> Integer.to_string(36)
|> String.downcase()
built_url = "http://orig01.deviantart.net/x_by_x-d#{base36}.png"
case PhilomenaProxy.Http.get(built_url) do
{:ok, %Tesla.Env{status: 301, headers: headers}} ->
# Location header provides URL of high res image.
{_location, link} = Enum.find(headers, fn {header, _val} -> header == "location" end)
%{
data
| images: [
%{
url: link,
camo_url: image.camo_url
}
]
}
_ ->
# Nothing to be found here, move along...
data
end
end
# Workaround for benoitc/hackney#273
defp follow_redirect(_url, 0), do: nil
defp follow_redirect(url, max_times) do
case PhilomenaProxy.Http.get(url) do
{:ok, %Tesla.Env{headers: headers, status: code}} when code in [301, 302] ->
location = Enum.find_value(headers, &location_header/1)
follow_redirect(location, max_times - 1)
response ->
response
end
end
defp location_header({"Location", location}), do: location
defp location_header({"location", location}), do: location
defp location_header(_), do: nil
end

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ defmodule PhilomenaProxy.Scrapers.Twitter do
[user, status_id] = Regex.run(@url_regex, url, capture: :all_but_first)
api_url = "https://api.fxtwitter.com/#{user}/status/#{status_id}"
{:ok, %Tesla.Env{status: 200, body: body}} = PhilomenaProxy.Http.get(api_url)
{:ok, %{status: 200, body: body}} = PhilomenaProxy.Http.get(api_url)
json = Jason.decode!(body)
tweet = json["tweet"]

View file

@ -13,13 +13,31 @@ defmodule PhilomenaQuery.Batch do
alias Philomena.Repo
import Ecto.Query
@typedoc """
Represents an object which may be operated on via `m:Ecto.Query`.
This could be a schema object (e.g. `m:Philomena.Images.Image`) or a fully formed query
`from i in Image, where: i.hidden_from_users == false`.
"""
@type queryable :: any()
@type batch_size :: {:batch_size, integer()}
@type id_field :: {:id_field, atom()}
@type batch_options :: [batch_size() | id_field()]
@typedoc """
The callback for `record_batches/3`.
Takes a list of schema structs which were returned in the batch. Return value is ignored.
"""
@type record_batch_callback :: ([struct()] -> any())
@typedoc """
The callback for `query_batches/3`.
Takes an `m:Ecto.Query` that can be processed with `m:Philomena.Repo` query commands, such
as `Philomena.Repo.update_all/3` or `Philomena.Repo.delete_all/2`. Return value is ignored.
"""
@type query_batch_callback :: ([Ecto.Query.t()] -> any())
@doc """

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
}
@typedoc """
User-supplied context argument.
Provided to `parse/3` and passed to the transform callback.
"""
@type context :: any()
@typedoc "Query in the search engine JSON query language."
@type query :: map()
@typedoc "Whether the default field is `:term` (not analyzed) or `:ngram` (analyzed)."
@type default_field_type :: :term | :ngram
@typedoc """
Return value of the transform callback.
On `{:ok, query}`, the query is incorporated into the parse tree at the current location.
On `{:error, error}`, parsing immediately stops and the error is returned from the parser.
"""
@type transform_result :: {:ok, query()} | {:error, String.t()}
@typedoc """
Type of the transform callback.
The transform callback receives the context argument passed to `parse/3` and the remainder of
the term. For instance `my:example` would match a transform rule with the key `"my"`, and
the remainder passed to the callback would be `"example"`.
"""
@type transform :: (context, String.t() -> transform_result())
@type t :: %__MODULE__{
@ -112,11 +134,11 @@ defmodule PhilomenaQuery.Parse.Parser do
aliases: %{"hidden" => "hidden_from_users"}
]
Parser.parser(options)
Parser.new(options)
"""
@spec parser(keyword()) :: t()
def parser(options) do
@spec new(keyword()) :: t()
def new(options) do
parser = struct(Parser, options)
fields =

View file

@ -18,10 +18,36 @@ defmodule PhilomenaQuery.Search do
# todo: fetch through compile_env?
@policy Philomena.SearchPolicy
@typedoc """
Any schema module which has an associated search index. See the policy module
for more information.
"""
@type schema_module :: @policy.schema_module()
@typedoc """
Represents an object which may be operated on via `m:Ecto.Query`.
This could be a schema object (e.g. `m:Philomena.Images.Image`) or a fully formed query
`from i in Image, where: i.hidden_from_users == false`.
"""
@type queryable :: any()
@typedoc """
A query body, as deliverable to any index's `_search` endpoint.
See the query DSL documentation for additional information:
https://opensearch.org/docs/latest/query-dsl/
"""
@type query_body :: map()
@typedoc """
Given a term at the given path, replace the old term with the new term.
`path` is a list of names to be followed to find the old term. For example,
a document containing `{"condiments": "dijon"}` would permit `["condiments"]`
as the path, and a document containing `{"namespaced_tags": {"name": ["old"]}}`
would permit `["namespaced_tags", "name"]` as the path.
"""
@type replacement :: %{
path: [String.t()],
old: term(),

View file

@ -1,11 +1,34 @@
defmodule PhilomenaQuery.SearchIndex do
# Returns the index name for the index.
# This is usually a collection name like "images".
@moduledoc """
Behaviour module for schemas with search indexing.
"""
@doc """
Returns the index name for the index.
This is usually a collection name like "images".
See https://opensearch.org/docs/latest/api-reference/index-apis/create-index/ for
reference on index naming restrictions.
"""
@callback index_name() :: String.t()
# Returns the mapping and settings for the index.
@doc """
Returns the mapping and settings for the index.
See https://opensearch.org/docs/latest/api-reference/index-apis/put-mapping/ for
reference on the mapping syntax, and the following pages for which types may be
used in mappings:
- https://opensearch.org/docs/latest/field-types/
- https://opensearch.org/docs/latest/analyzers/index-analyzers/
"""
@callback mapping() :: map()
# Returns the JSON representation of the given struct for indexing in OpenSearch.
@doc """
Returns the JSON representation of the given struct for indexing in OpenSearch.
See https://opensearch.org/docs/latest/api-reference/document-apis/index-document/ for
reference on how this value is used.
"""
@callback as_json(struct()) :: map()
end

View file

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

View file

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

View file

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

View file

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

View file

@ -212,7 +212,7 @@ defmodule PhilomenaWeb.ProfileController do
end
defp individual_stat(mapping, stat_name) do
Enum.map(89..0, &(map_fetch(mapping[&1], stat_name) || 0))
Enum.map(89..0//-1, &(map_fetch(mapping[&1], stat_name) || 0))
end
defp map_fetch(nil, _field_name), do: nil

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()
case PhilomenaProxy.Http.get(make_api_url(prefix)) do
{:ok, %Tesla.Env{body: body, status: 200}} -> String.contains?(body, rest)
{:ok, %{body: body, status: 200}} -> String.contains?(body, rest)
_ -> false
end
end

View file

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

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(conn) do
conn = Conn.fetch_cookies(conn)
case conn.cookies["_ses"] do
case conn.assigns.fingerprint do
nil ->
PhilomenaWeb.NotAuthorizedPlug.call(conn)

View file

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

View file

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

View file

@ -1,6 +1,6 @@
h1 Editing ban
= render PhilomenaWeb.Admin.FingerprintBanView, "_form.html", changeset: @changeset, action: ~p"/admin/fingerprint_bans/#{@fingerprint}", conn: @conn
= render PhilomenaWeb.Admin.FingerprintBanView, "_form.html", changeset: @changeset, action: ~p"/admin/fingerprint_bans/#{@fingerprint_ban}", conn: @conn
br
= link "Back", to: ~p"/admin/fingerprint_bans"

View file

@ -59,5 +59,5 @@ h1 = @conversation.title
p You've managed to send over 1,000 messages in this conversation!
p We'd like to ask you to make a new conversation. Don't worry, this one won't go anywhere if you need to refer back to it.
p
=> link "Click here", to: ~p"/conversations/new?#{[receipient: other.name]}"
=> link "Click here", to: ~p"/conversations/new?#{[recipient: other.name]}"
' to make a new conversation with this user.

View file

@ -73,7 +73,7 @@
.fieldlabel
strong You probably do not want to check this unless you know what you are doing - it cannot be changed later
| . Pulic filters can be shared with other users and used by them; if you make changes to a filter, it will update all users of that filter.
| . Public filters can be shared with other users and used by them; if you make changes to a filter, it will update all users of that filter.
- input_value(f, :public) == true ->
.fieldlabel

View file

@ -8,7 +8,7 @@
p
'The page(s) you found this image on. Images may have a maximum of
span.js-max-source-count> 10
span.js-max-source-count> 15
' source URLs. Leave any sources you don't want to use blank.
= inputs_for f, :sources, [as: "image[old_sources]", skip_hidden: true], fn fs ->

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@
.field
=> label f, :categories, "Art Categories:"
br
= collection_checkboxes f, :categories, categories(), selected: f.data.categories, input_opts: [ class: 'checkbox spacing-right' ], wrapper: &Phoenix.HTML.Tag.content_tag(:span, &1, class: "commission__category")
= collection_checkboxes f, :categories, categories(), selected: f.data.categories, input_opts: [ class: "checkbox spacing-right" ], wrapper: &Phoenix.HTML.Tag.content_tag(:span, &1, class: "commission__category")
= error_tag f, :categories
.field
=> label f, :sheet_image_id, "Image ID of your commissions sheet (optional but recommended):"

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}"
/ The order of tag states and dropdown links is important for tags.js
/ The order of tag states and dropdown links is important for tags.ts
span
span.tag__state.hidden title="Unwatched"
| +

View file

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

View file

@ -211,9 +211,8 @@ defmodule PhilomenaWeb.UserAuth do
defp update_usages(conn, user) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
conn = fetch_cookies(conn)
UserIpUpdater.cast(user.id, conn.remote_ip, now)
UserFingerprintUpdater.cast(user.id, conn.cookies["_ses"], now)
UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now)
end
end

View file

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

View file

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

22
mix.exs
View file

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

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"},
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"},
"canary": {:git, "https://github.com/marcinkoziej/canary.git", "704debde7a2c0600f78c687807884bf37c45bd79", [ref: "704debde7a2c0600f78c687807884bf37c45bd79"]},
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
"castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
"credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
"ecto_network": {:hex, :ecto_network, "1.5.0", "a930c910975e7a91237b858ebf0f4ad7b2aae32fa846275aa203cb858459ec73", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "4d614434ae3e6d373a2f693d56aafaa3f3349714668ffd6d24e760caf578aa2f"},
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
"ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"},
"elastix": {:hex, :elastix, "0.10.0", "7567da885677ba9deffc20063db5f3ca8cd10f23cff1ab3ed9c52b7063b7e340", [:mix], [{:httpoison, "~> 1.4", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:retry, "~> 0.8", [hex: :retry, repo: "hexpm", optional: false]}], "hexpm", "5fb342ce068b20f7845f5dd198c2dc80d967deafaa940a6e51b846db82696d1d"},
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
"elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
"elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_aws": {:git, "https://github.com/liamwhite/ex_aws.git", "a340859dd8ac4d63bd7a3948f0994e493e49bda4", [ref: "a340859dd8ac4d63bd7a3948f0994e493e49bda4"]},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"},
"ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"},
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
"exq": {:hex, :exq, "0.19.0", "06eb92944dad39f0954dc8f63190d3e24d11734eef88cf5800883e57ebf74f3c", [:mix], [{:elixir_uuid, ">= 1.2.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0 and < 6.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:redix, ">= 0.9.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "24fc0ebdd87cc7406e1034fb46c2419f9c8a362f0ec634d23b6b819514d36390"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"},
"finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"hut": {:hex, :hut, "1.4.0", "7a1238ec00f95c9ec75412587ee11ac652eca308a7f4b8cc9629746d579d6cf0", [:"erlang.mk", :rebar3], [], "hexpm", "7af8704b9bae98a336f70d9560fc3c97f15665265fa603dbd05352e63d6ebb03"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"},
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
"mix_audit": {:hex, :mix_audit, "2.1.2", "6cd5c5e2edbc9298629c85347b39fb3210656e541153826efd0b2a63767f3395", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "68d2f06f96b9c445a23434c9d5f09682866a5b4e90f631829db1c64f140e795b"},
"mua": {:hex, :mua, "0.2.1", "7f1c20dbe7266d514a07bf5b7a3946413d70150be41cb5475b5a95bb517a378f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0bc556803a1d09dfb69bfebecb838cf33a2d123de84f700c41b6b8134027c11f"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"},
"mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"},
"mua": {:hex, :mua, "0.2.2", "d2997abc1eee43d91e4a355665658743ad2609b8d5992425940ce17b7ff87933", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "cda7e38c65d3105b3017b25ac402b4c9457892abeb2e11c331b25a92d16b04c0"},
"neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"},
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]},
"phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.5.0", "1a1f841ccda19b15f1d82968840a5b895c5f687b6734e430e4b2dbe035ca1837", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "13990570fde09e16959ef214501fe2813e1192d62ca753ec8798980580436f94"},
"phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.1", "6ab463cf43938ee9906067b33c8d66782343de4280a70084cd5617accc6345a8", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "e8467d308b61f294f68afe12c81bf585584c7ceed40ec8adde88ec176d480a78"},
"phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"},
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"},
"phoenix_slime": {:git, "https://github.com/slime-lang/phoenix_slime.git", "8944de91654d6fcf6bdcc0aed6b8647fe3398241", [ref: "8944de91654d6fcf6bdcc0aed6b8647fe3398241"]},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"},
"plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.0", "3ae9369c60641084363b08fe90267cbdd316df57e3557ea522114b30b63256ea", [:mix], [{:cowboy, "~> 2.7.0 or ~> 2.8.0 or ~> 2.9.0 or ~> 2.10.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d85444fb8aa1f2fc62eabe83bbe387d81510d773886774ebdcb429b3da3c1a4a"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
"plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"},
"postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"},
"pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"},
"qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"},
"ranch": {:hex, :ranch, "2.1.0", "2261f9ed9574dcfcc444106b9f6da155e6e540b2f82ba3d42b339b93673b72a3", [:make, :rebar3], [], "hexpm", "244ee3fa2a6175270d8e1fc59024fd9dbc76294a321057de8f803b1479e76916"},
"redix": {:hex, :redix, "1.3.0", "f4121163ff9d73bf72157539ff23b13e38422284520bb58c05e014b19d6f0577", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "60d483d320c77329c8cbd3df73007e51b23f3fae75b7693bc31120d83ab26131"},
"remote_ip": {:hex, :remote_ip, "1.1.0", "cb308841595d15df3f9073b7c39243a1dd6ca56e5020295cb012c76fbec50f2d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "616ffdf66aaad6a72fc546dabf42eed87e2a99e97b09cbd92b10cc180d02ed74"},
"redix": {:hex, :redix, "1.5.1", "a2386971e69bf23630fb3a215a831b5478d2ee7dc9ea7ac811ed89186ab5d7b7", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "85224eb2b683c516b80d472eb89b76067d5866913bf0be59d646f550de71f5c4"},
"remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"},
"req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"},
"retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"},
"rustler": {:hex, :rustler, "0.31.0", "7e5eefe61e6e6f8901e5aa3de60073d360c6320d9ec363027b0197297b80c46a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "99e378459bfb9c3bda6d3548b2b3bc6f9ad97f728f76bdbae7bf5c770a4f8abd"},
"rustler": {:hex, :rustler, "0.33.0", "4a5b0a7a7b0b51549bea49947beff6fae9bc5d5326104dcd4531261e876b5619", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "7c4752728fee59a815ffd20c3429c55b644041f25129b29cdeb5c470b80ec5fd"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"},
"slime": {:git, "https://github.com/liamwhite/slime.git", "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31", [ref: "4c8ad4e9e9dcc792f4db769a9ef2ad7d6eba8f31"]},
"slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"},
"swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"},
"websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
}

View file

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

View file

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