4 Commits

Author SHA1 Message Date
Dylan Knutson
430247a3ad normalize fa cdn hosts to avoid redownloading files 2025-07-24 16:19:41 +00:00
Dylan Knutson
19fc98e4ef e621 post aux table migration 2025-07-24 15:44:50 +00:00
Dylan Knutson
ad229fbd4e position user search bar results 2025-07-23 17:59:10 +00:00
Dylan Knutson
8333a1bb3f home page has links on it 2025-07-23 17:14:27 +00:00
23 changed files with 551 additions and 557 deletions

View File

@@ -35,8 +35,8 @@
// "postCreateCommand": "bundle install && rake db:setup",
"postCreateCommand": ".devcontainer/post-create.sh",
"forwardPorts": [
3000, // rails development
3001, // rails staging
3000, // rails
3001, // thrust
9394, // prometheus exporter
"pgadmin:8080", // pgadmin
"grafana:3100", // grafana

View File

@@ -4,6 +4,10 @@
"trailingComma": "all",
"arrowParens": "always",
"singleQuote": true,
"semi": true,
"bracketSpacing": true,
"bracketSameLine": false,
"printWidth": 80,
"plugins": [
"prettier-plugin-tailwindcss",
"@prettier/plugin-ruby",
@@ -11,5 +15,13 @@
"@4az/prettier-plugin-html-erb"
],
"xmlQuoteAttributes": "double",
"xmlWhitespaceSensitivity": "ignore"
"xmlWhitespaceSensitivity": "ignore",
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"options": {
"parser": "typescript"
}
}
]
}

View File

@@ -144,11 +144,11 @@ gem "attr_json"
group :production, :staging do
gem "rails_semantic_logger", "~> 4.17"
gem "cloudflare-rails"
end
group :production do
gem "sd_notify"
gem "cloudflare-rails"
end
gem "rack", "~> 2.2"

View File

@@ -1,4 +1,4 @@
rails: RAILS_ENV=staging HTTP_PORT=3001 TARGET_PORT=3002 bundle exec thrust ./bin/rails server -p 3002
rails: RAILS_ENV=staging HTTP_PORT=3001 bundle exec thrust ./bin/rails server
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
css: RAILS_ENV=development yarn "build:css[debug]" --watch

View File

@@ -29,11 +29,11 @@ export default function ListItem({
subtext,
domainIcon,
}: PropTypes) {
const groupHoverClassName = 'group-hover:text-slate-200';
const iconClassName = ['ml-2'];
const textClassName = [
COMMON_LIST_ELEM_CLASSES,
'relative flex items-center justify-between',
'border-t-0',
'group flex items-center justify-between',
isLast && 'rounded-b-lg',
style === 'item' && selected && 'bg-slate-700 text-slate-100',
style === 'info' && 'text-slate-500 italic',
@@ -66,12 +66,21 @@ export default function ListItem({
</div>
<div className="inline-block flex-grow pl-1">{value}</div>
{subtext && (
<div className="vertical-align-middle float-right inline-block pl-1 text-sm italic text-slate-500">
<div
className={[
'vertical-align-middle float-right inline-block pl-1 text-sm italic',
!selected && 'text-slate-500',
selected && 'text-slate-300',
groupHoverClassName,
]
.filter(Boolean)
.join(' ')}
>
{subtext}
</div>
)}
{domainIcon && (
<img src={domainIcon} alt="domain icon" className="inline w-6 pl-1" />
<img src={domainIcon} alt="domain icon" className="inline w-6" />
)}
</div>
</a>

View File

@@ -44,9 +44,9 @@ export const PostHoverPreviewWrapper: React.FC<
className={anchorClassNamesForVisualStyle(visualStyle, true)}
>
{postDomainIcon && (
<img
src={postDomainIcon}
alt={postThumbnailAlt}
<img
src={postDomainIcon}
alt={postThumbnailAlt}
className={iconClassNamesForSize('small')}
/>
)}

View File

@@ -44,7 +44,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
};
const sortedData = React.useMemo(() => {
const headerIndex = headers.findIndex(h => h.key === sortKey);
const headerIndex = headers.findIndex((h) => h.key === sortKey);
if (headerIndex === -1) return data;
return [...data].sort((a, b) => {
@@ -92,7 +92,13 @@ export const SortableTable: React.FC<SortableTableProps> = ({
const getSortIndicator = (headerKey: string) => {
if (sortKey !== headerKey) {
return <span style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}></span>;
return (
<span
style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}
>
</span>
);
}
return (
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
@@ -112,7 +118,9 @@ export const SortableTable: React.FC<SortableTableProps> = ({
style={{
...headerStyle,
textAlign: header.align,
...(index === headers.length - 1 ? { borderRight: 'none' } : {}),
...(index === headers.length - 1
? { borderRight: 'none' }
: {}),
}}
onClick={() => handleSort(header.key)}
onMouseEnter={(e) => {
@@ -130,7 +138,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
{/* Data Rows */}
{sortedData.map((row) => (
<div key={row.id} className="contents group">
<div key={row.id} className="group contents">
{row.cells.map((cell, cellIndex) => (
<div
key={cellIndex}
@@ -140,9 +148,11 @@ export const SortableTable: React.FC<SortableTableProps> = ({
fontSize: '0.875rem',
fontWeight: cellIndex === 0 ? 500 : 400,
color: cellIndex === 0 ? '#0f172a' : '#64748b',
...(cellIndex === row.cells.length - 1 ? { borderRight: 'none' } : {}),
...(cellIndex === row.cells.length - 1
? { borderRight: 'none' }
: {}),
}}
className="group-hover:bg-slate-50 transition-colors duration-150"
className="transition-colors duration-150 group-hover:bg-slate-50"
>
{cell.value}
</div>

View File

@@ -28,7 +28,8 @@ export const StatsCard: React.FC<StatsCardProps> = ({
<div className="text-xl font-bold text-slate-900">
{requestCount} requests
<span className="text-base font-normal text-slate-600">
{' '}in last {timeWindow}
{' '}
in last {timeWindow}
</span>
</div>
<div className="mt-1 text-slate-600">

View File

@@ -42,117 +42,137 @@ export const StatsPage: React.FC<StatsPageProps> = ({
byDomainCounts,
availableTimeWindows,
}) => {
const contentTypeData: TableData[] = contentTypeCounts.map(item => ({
const contentTypeData: TableData[] = contentTypeCounts.map((item) => ({
id: item.content_type,
cells: [
{ value: item.content_type, sortKey: item.content_type },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes }
]
{ value: item.bytesFormatted, sortKey: item.bytes },
],
}));
const domainData: TableData[] = byDomainCounts.map(item => ({
const domainData: TableData[] = byDomainCounts.map((item) => ({
id: item.domain,
cells: [
{ value: item.domain, sortKey: item.domain },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes }
]
{ value: item.bytesFormatted, sortKey: item.bytes },
],
}));
return (
<div className="mx-auto max-w-7xl px-4 mt-8">
<div className="mx-auto mt-8 max-w-7xl px-4">
{/* Header Section */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
{/* Top Bar */}
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
<div className="border-b border-slate-200 bg-slate-50 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">HTTP Request Analytics</h1>
<a
href="/log_entries"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white border border-slate-300 rounded-lg transition-colors hover:shadow-sm"
>
<i className="fas fa-arrow-left" />
Back to Log Entries
</a>
<h1 className="text-2xl font-bold text-slate-900">
HTTP Request Analytics
</h1>
<a
href="/log_entries"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-white hover:text-slate-900 hover:shadow-sm"
>
<i className="fas fa-arrow-left" />
Back to Log Entries
</a>
</div>
</div>
{/* Stats Summary */}
<div className="px-6 py-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-t border-slate-200">
<div className="text-center mb-4">
<h3 className="text-lg font-semibold text-slate-900 mb-1">Summary for {timeWindowFormatted}</h3>
<div className="border-t border-slate-200 bg-gradient-to-br from-blue-50 to-indigo-50 px-6 py-6">
<div className="mb-4 text-center">
<h3 className="mb-1 text-lg font-semibold text-slate-900">
Summary for {timeWindowFormatted}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Total Requests */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Requests</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{lastWindowCount.toLocaleString()}</p>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Total Requests
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{lastWindowCount.toLocaleString()}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<i className="fas fa-chart-bar text-blue-600" />
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i className="fas fa-chart-bar text-blue-600" />
</div>
</div>
</div>
{/* Requests per Second */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Requests/sec</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{requestsPerSecond}</p>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Requests/sec
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{requestsPerSecond}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<i className="fas fa-bolt text-green-600" />
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i className="fas fa-bolt text-green-600" />
</div>
</div>
</div>
{/* Total Data */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Data</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{totalBytesFormatted}</p>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Total Data
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{totalBytesFormatted}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<i className="fas fa-database text-purple-600" />
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i className="fas fa-database text-purple-600" />
</div>
</div>
</div>
{/* Data per Second */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Data/sec</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{bytesPerSecondFormatted}</p>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Data/sec
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">
{bytesPerSecondFormatted}
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
<i className="fas fa-download text-orange-600" />
</div>
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<i className="fas fa-download text-orange-600" />
</div>
</div>
</div>
</div>
</div>
{/* Time Window Selector */}
<div className="px-4 py-4 bg-gradient-to-br from-blue-50 to-indigo-50">
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 px-4 py-4">
<div className="text-center">
<div className="inline-flex flex-wrap justify-center bg-white/70 backdrop-blur-sm rounded-lg p-1 shadow-md border border-white/50 gap-1">
<div className="inline-flex flex-wrap justify-center gap-1 rounded-lg border border-white/50 bg-white/70 p-1 shadow-md backdrop-blur-sm">
{availableTimeWindows.map((timeWindowOption, index) => (
<React.Fragment key={timeWindowOption.seconds}>
{timeWindowOption.active ? (
<span className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-md shadow-sm">
<span className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm">
{timeWindowOption.label}
</span>
) : (
<a
href={timeWindowOption.path}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white hover:shadow-sm rounded-md transition-colors border border-transparent hover:border-slate-200"
className="rounded-md border border-transparent px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-slate-200 hover:bg-white hover:text-slate-900 hover:shadow-sm"
>
{timeWindowOption.label}
</a>
@@ -165,14 +185,16 @@ export const StatsPage: React.FC<StatsPageProps> = ({
</div>
{/* Tables Grid - 2 columns */}
<div className="my-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="my-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<h2 className="text-xl font-bold text-slate-900 mb-3">By Content Type</h2>
<h2 className="mb-3 text-xl font-bold text-slate-900">
By Content Type
</h2>
<SortableTable
headers={[
{ label: 'Content Type', key: 'content_type', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' }
{ label: 'Transferred', key: 'bytes', align: 'right' },
]}
data={contentTypeData}
defaultSortKey="count"
@@ -181,12 +203,12 @@ export const StatsPage: React.FC<StatsPageProps> = ({
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 mb-3">By Domain</h2>
<h2 className="mb-3 text-xl font-bold text-slate-900">By Domain</h2>
<SortableTable
headers={[
{ label: 'Domain', key: 'domain', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' }
{ label: 'Transferred', key: 'bytes', align: 'right' },
]}
data={domainData}
defaultSortKey="bytes"

View File

@@ -3,7 +3,6 @@ import * as React from 'react';
import { useCallback, useRef, useState } from 'react';
import Icon from './Icon';
import ListItem from './ListItem';
import Trie, { TrieNode } from '../lib/Trie';
// 1. Group related constants
const CONFIG = {
@@ -18,7 +17,7 @@ const STYLES = {
],
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
INPUT_CLASSNAME: `text-slate-500 group-focus-within:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
INPUT_CLASSNAME: `text-slate-500 focus:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
} as const;
// 2. Simplify logging
@@ -46,10 +45,6 @@ interface ServerResponse {
users: User[];
}
type TrieValue = [number, string];
type TrieType = Trie<TrieValue>;
type TrieNodeType = TrieNode<TrieValue>;
export default function UserSearchBar({ isServerRendered }: PropTypes) {
isServerRendered = !!isServerRendered;
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
@@ -119,7 +114,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
}
} catch (err) {
if (!err.message.includes('aborted')) {
log.error('error loading user trie: ', err);
setState((s) => ({
...s,
errorMessage: `error loading users: ` + err.message,
@@ -148,7 +142,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
const searchForUserDebounced = useCallback(
debounce(async (userName) => {
log.info('sending search for ', userName);
setState((s) => ({ ...s, typingSettled: true }));
searchForUser(userName);
}, 250),
@@ -158,7 +151,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
function invokeIdx(idx) {
const user = state.userList[idx];
if (user) {
log.info('selecting user: ', user);
setState((s) => ({ ...s, userName: user.name }));
inputRef.current.value = user.name;
window.location.href = user.show_path;
@@ -185,9 +177,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
function UserSearchBarItems() {
return (
<div
className={`${anyShown || 'border-b-0'} divide-y divide-inherit rounded-b-lg border border-t-0 border-inherit`}
>
<div className="divide-y divide-slate-300 overflow-hidden border border-slate-300 bg-slate-50 shadow-lg sm:rounded-xl">
{visibility.error ? (
<ListItem
key="error"
@@ -267,7 +257,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
return (
<div
className={[
'group mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
'relative mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
'focus-within:border-slate-400 sm:max-w-md',
'border-slate-300 bg-slate-50 p-2 shadow-lg',
].join(' ')}
@@ -290,7 +280,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
STYLES.INPUT_CLASSNAME,
'rounded-lg outline-none',
'bg-slate-50 placeholder:italic',
anyShown && 'rounded-b-none',
]
.filter(Boolean)
.join(' ')}
@@ -306,7 +295,11 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
ref={inputRef}
/>
</label>
<UserSearchBarItems />
{anyShown && (
<div className="absolute left-0 right-0 top-full z-50 mt-1">
<UserSearchBarItems />
</div>
)}
</div>
);
}

View File

@@ -1,91 +0,0 @@
interface SerializedTrie<T> {
// terminal node?
t: 1 | 0;
// value of the node
v: T;
// optional children
c?: { [s: string]: SerializedTrie<T> };
}
export class TrieNode<T> {
public terminal: boolean;
public value: T;
public children: Map<string, TrieNode<T>>;
public serialized: SerializedTrie<T>;
constructor(ser: SerializedTrie<T>) {
this.terminal = ser.t == 1;
this.value = ser.v;
this.children = new Map();
this.serialized = ser;
if (ser.c != null) {
for (const [key, value] of Object.entries(ser.c)) {
this.children.set(key, new TrieNode(value));
}
}
}
}
export default class Trie<T> {
public root: TrieNode<T>;
constructor(ser: SerializedTrie<T>) {
this.root = new TrieNode(ser);
}
public nodeForPrefix(key: string): {
chain: string[];
node: TrieNode<T> | null;
} {
let chain = [];
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let exactChild = null;
console.log('remaining: ', remaining);
for (const [childKey, child] of node.children.entries()) {
if (remaining.startsWith(childKey)) {
console.log('exact match for: ', childKey);
exactChild = child;
chain.push(childKey);
remaining = remaining.slice(childKey.length);
break;
}
}
// if an exact match was found, continue iterating
if (exactChild) {
node = exactChild;
continue;
}
console.log('looking for partial match for ', remaining);
for (const [childKey, child] of node.children.entries()) {
const startsWith = childKey.startsWith(remaining);
console.log(
'test ',
childKey,
' against ',
remaining,
': ',
startsWith,
' ',
child.serialized,
);
if (startsWith) {
console.log('partial match for: ', remaining, ': ', child.serialized);
chain.push(childKey);
return { chain, node: child };
}
}
console.log('did not find partial, bailing!');
return { chain, node: null };
}
// // return remaining.length === 0 && node && node.terminal ? node : null;
console.log('returning child ', node, ' for remaining ', remaining);
return { chain, node };
}
}

View File

@@ -1,140 +0,0 @@
function buildUsersTrie(users) {
const rootNode = new trie();
users.forEach(([id, name]) => {
rootNode.insert(name.toLowerCase(), [id, name]);
});
return JSON.stringify(rootNode.serialize());
}
class trie_node {
constructor() {
this.terminal = false;
this.children = new Map();
}
serialize() {
const { terminal, value, children } = this;
let mapped = {};
let numChildren = 0;
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
numChildren += 1;
mapped[childKey] = children.get(childKey).serialize();
});
return {
t: this.terminal ? 1 : 0,
v: value,
c: numChildren > 0 ? mapped : undefined,
};
}
}
class trie {
constructor() {
this.root = new trie_node();
this.elements = 0;
}
serialize() {
return this.root.serialize();
}
get length() {
return this.elements;
}
get(key) {
const node = this.getNode(key);
if (node) {
return node.value;
}
return null;
}
contains(key) {
const node = this.getNode(key);
return !!node;
}
insert(key, value) {
let node = this.root;
let remaining = key;
while (remaining.length > 0) {
let child = null;
for (const childKey of node.children.keys()) {
const prefix = this.commonPrefix(remaining, childKey);
if (!prefix.length) {
continue;
}
if (prefix.length === childKey.length) {
// enter child node
child = node.children.get(childKey);
remaining = remaining.slice(childKey.length);
break;
}
else {
// split the child
child = new trie_node();
child.children.set(childKey.slice(prefix.length), node.children.get(childKey));
node.children.delete(childKey);
node.children.set(prefix, child);
remaining = remaining.slice(prefix.length);
break;
}
}
if (!child && remaining.length) {
child = new trie_node();
node.children.set(remaining, child);
remaining = "";
}
node = child;
}
if (!node.terminal) {
node.terminal = true;
this.elements += 1;
}
node.value = value;
}
remove(key) {
const node = this.getNode(key);
if (node) {
node.terminal = false;
this.elements -= 1;
}
}
map(prefix, func) {
const mapped = [];
const node = this.getNode(prefix);
const stack = [];
if (node) {
stack.push([prefix, node]);
}
while (stack.length) {
const [key, node] = stack.pop();
if (node.terminal) {
mapped.push(func(key, node.value));
}
for (const c of node.children.keys()) {
stack.push([key + c, node.children.get(c)]);
}
}
return mapped;
}
getNode(key) {
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let child = null;
for (let i = 1; i <= remaining.length; i += 1) {
child = node.children.get(remaining.slice(0, i));
if (child) {
remaining = remaining.slice(i);
break;
}
}
node = child;
}
return remaining.length === 0 && node && node.terminal ? node : null;
}
commonPrefix(a, b) {
const shortest = Math.min(a.length, b.length);
let i = 0;
for (; i < shortest; i += 1) {
if (a[i] !== b[i]) {
break;
}
}
return a.slice(0, i);
}
}

View File

@@ -1,163 +0,0 @@
type UserRow = [number, string];
function buildUsersTrie(users: UserRow[]): string {
const rootNode = new trie<[number, string]>();
users.forEach(([id, name]) => {
rootNode.insert(name.toLowerCase(), [id, name]);
});
return JSON.stringify(rootNode.serialize());
}
class trie_node<T> {
public terminal: boolean;
public value: T;
public children: Map<string, trie_node<T>>;
constructor() {
this.terminal = false;
this.children = new Map();
}
public serialize(): Object {
const { terminal, value, children } = this;
let mapped = {};
let numChildren = 0;
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
numChildren += 1;
mapped[childKey] = children.get(childKey).serialize();
});
return {
t: this.terminal ? 1 : 0,
v: value,
c: numChildren > 0 ? mapped : undefined,
};
}
}
class trie<T> {
public root: trie_node<T>;
public elements: number;
constructor() {
this.root = new trie_node<T>();
this.elements = 0;
}
public serialize(): Object {
return this.root.serialize();
}
public get length(): number {
return this.elements;
}
public get(key: string): T | null {
const node = this.getNode(key);
if (node) {
return node.value;
}
return null;
}
public contains(key: string): boolean {
const node = this.getNode(key);
return !!node;
}
public insert(key: string, value: T): void {
let node = this.root;
let remaining = key;
while (remaining.length > 0) {
let child: trie_node<T> = null;
for (const childKey of node.children.keys()) {
const prefix = this.commonPrefix(remaining, childKey);
if (!prefix.length) {
continue;
}
if (prefix.length === childKey.length) {
// enter child node
child = node.children.get(childKey);
remaining = remaining.slice(childKey.length);
break;
} else {
// split the child
child = new trie_node<T>();
child.children.set(
childKey.slice(prefix.length),
node.children.get(childKey)
);
node.children.delete(childKey);
node.children.set(prefix, child);
remaining = remaining.slice(prefix.length);
break;
}
}
if (!child && remaining.length) {
child = new trie_node<T>();
node.children.set(remaining, child);
remaining = "";
}
node = child;
}
if (!node.terminal) {
node.terminal = true;
this.elements += 1;
}
node.value = value;
}
public remove(key: string): void {
const node = this.getNode(key);
if (node) {
node.terminal = false;
this.elements -= 1;
}
}
public map<U>(prefix: string, func: (key: string, value: T) => U): U[] {
const mapped = [];
const node = this.getNode(prefix);
const stack: [string, trie_node<T>][] = [];
if (node) {
stack.push([prefix, node]);
}
while (stack.length) {
const [key, node] = stack.pop();
if (node.terminal) {
mapped.push(func(key, node.value));
}
for (const c of node.children.keys()) {
stack.push([key + c, node.children.get(c)]);
}
}
return mapped;
}
private getNode(key: string): trie_node<T> | null {
let node = this.root;
let remaining = key;
while (node && remaining.length > 0) {
let child = null;
for (let i = 1; i <= remaining.length; i += 1) {
child = node.children.get(remaining.slice(0, i));
if (child) {
remaining = remaining.slice(i);
break;
}
}
node = child;
}
return remaining.length === 0 && node && node.terminal ? node : null;
}
private commonPrefix(a: string, b: string): string {
const shortest = Math.min(a.length, b.length);
let i = 0;
for (; i < shortest; i += 1) {
if (a[i] !== b[i]) {
break;
}
}
return a.slice(0, i);
}
}

View File

@@ -117,11 +117,25 @@ class Domain::Fa::Job::ScanPostJob < Domain::Fa::Job::Base
uri = Addressable::URI.parse(submission.full_res_img)
uri.scheme = "https" if uri.scheme.blank?
if (file = post.file) && (file.url_str != uri.to_s)
file = post.files.build(url_str: uri.to_s)
else
file = post.file || post.build_file(url_str: uri.to_s)
# resolve the existing file and check if the URL has changed.
# sometimes, the domain will change from `d.facdn.net` to
# `d.furaffinity.net`, and we want to ignore the change in that case
file = post.file
if file && (old_url_str = file.url_str) && (old_url_str != uri.to_s)
if self.class.uri_same_with_normalized_facdn_host?(old_url_str, uri.to_s)
logger.info(
format_tags(
make_tag("old_url_str", old_url_str),
make_tag("new_url_str", uri.to_s),
"file url has changed, but is the same domain",
),
)
else
file = post.files.build(url_str: uri.to_s)
end
end
file ||= post.build_file(url_str: uri.to_s)
if file.url_str_changed?
file.enqueue_job_after_save(
Domain::Fa::Job::ScanFileJob,
@@ -142,4 +156,22 @@ class Domain::Fa::Job::ScanPostJob < Domain::Fa::Job::Base
post.posted_at = submission.posted_date&.in_time_zone("UTC")
post.scanned_at = Time.now
end
FA_CDN_HOSTS = %w[d.facdn.net d.furaffinity.net].freeze
sig { params(url_str: String, new_url_str: String).returns(T::Boolean) }
def self.uri_same_with_normalized_facdn_host?(url_str, new_url_str)
uri = Addressable::URI.parse(url_str)
new_uri = Addressable::URI.parse(new_url_str)
uri.scheme = nil
new_uri.scheme = nil
if [uri, new_uri].all? { |uri| FA_CDN_HOSTS.include?(uri.host) }
# both URIs have an facdn host, so compare them but ignore the host
uri.host = nil
new_uri.host = nil
uri == new_uri
else
url_str == new_url_str
end
end
end

View File

@@ -1,6 +1,7 @@
# typed: strict
class Domain::Post < ReduxApplicationRecord
extend T::Helpers
include HasAuxTable
include HasCompositeToParam
include HasViewPrefix
include AttrJsonRecordAliases

View File

@@ -1,35 +1,6 @@
# typed: strict
class Domain::Post::E621Post < Domain::Post
attr_json :state, :string
attr_json :e621_id, :integer
# When was the post's /posts/<post_id>/favorites pages scanned?
# Used to identify users with a significant number of favorites, setting
# their `num_other_favs_cached` attribute
attr_json :scanned_post_favs_at, ActiveModelUtcTimeValue.new
attr_json :rating, :string
attr_json :tags_array, ActiveModel::Type::Value.new
attr_json :flags_array, :string, array: true
attr_json :pools_array, :string, array: true
attr_json :sources_array, :string, array: true
attr_json :artists_array, :string, array: true
attr_json :e621_updated_at, ActiveModelUtcTimeValue.new
attr_json :parent_post_e621_id, :integer
attr_json :last_index_page_id, :integer
attr_json :caused_by_entry_id, :integer
attr_json :scan_log_entry_id, :integer
attr_json :index_page_ids, :integer, array: true
attr_json :description, :string
attr_json :score, :integer
attr_json :score_up, :integer
attr_json :score_down, :integer
attr_json :num_favorites, :integer
attr_json :num_comments, :integer
attr_json :change_seq, :integer
attr_json :md5, :string
attr_json :prev_md5s, :string, array: true
attr_json :scan_error, :string
attr_json :uploader_user_id, :integer
aux_table :e621
has_single_file!
has_faving_users! Domain::User::E621User

View File

@@ -20,20 +20,6 @@
<div class="w-full max-w-2xl mx-auto mt-4 text-center sm:mt-6">
<% index_type_header_partial = "domain/posts/index_type_headers/#{@posts_index_view_config.index_type_header}" %>
<%= render partial: index_type_header_partial, locals: { user: @user, params: params, posts: @posts } %>
<div class="mt-4 flex justify-center gap-4">
<% if params[:view] == "table" %>
<%= link_to domain_posts_path(view: "gallery"), class: "text-blue-600 hover:text-blue-800" do %>
<i class="fas fa-th-large"></i> Gallery View
<% end %>
<% else %>
<%= link_to domain_posts_path(view: "table"), class: "text-blue-600 hover:text-blue-800" do %>
<i class="fas fa-table"></i> Table View
<% end %>
<% end %>
<%= link_to visual_search_domain_posts_path, class: "text-blue-600 hover:text-blue-800" do %>
<i class="fas fa-search"></i> Visual Search
<% end %>
</div>
</div>
<% if @posts_index_view_config.show_domain_filters %>
<%= render partial: "domain_filter_controls" %>

View File

@@ -1,32 +1,57 @@
<% content_for :head do %>
<%# <%= javascript_pack_tag "application-bundle" %>
<% end %>
<div class="mt-2 sm:m-2 sm:p-4">
<%= react_component("UserSearchBar", props: {}, prerender: true, strict_mode: true) %>
<div
class="mx-auto mt-2 w-full border-y border-slate-300 bg-slate-50 p-2 shadow-lg sm:max-w-md sm:rounded-xl sm:border"
>
<div class="space-y-4 p-1">
<div class="border-b border-slate-200 pb-3">
<h2 class="font-medium text-slate-700">Questions? Comments? Suggestions?</h2>
<div class="mt-2 text-sm text-slate-600">
Contact @DeltaNoises on:
<div class="mt-1 flex items-center gap-3">
<%= link_to "https://t.me/DeltaNoises", target: "_blank", class: "blue-link inline-flex items-center gap-1" do %>
<i class="fab fa-telegram"></i> Telegram
<% end %>
<%= link_to "https://bsky.app/profile/delta.refurrer.com", target: "_blank", class: "blue-link inline-flex items-center gap-1" do %>
<i class="fas fa-cloud"></i> BlueSky
<% end %>
<!-- Primary Action: User Search -->
<div class="mx-auto max-w-2xl">
<%= react_component("UserSearchBar", props: {}, prerender: true, strict_mode: true) %>
</div>
<!-- Navigation Section -->
<div class="mx-auto mt-8 max-w-2xl">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid gap-4 sm:grid-cols-2">
<%= link_to domain_posts_path, class: "group flex items-center justify-center gap-3 rounded-lg bg-slate-50 border border-slate-200 p-4 hover:bg-blue-50 hover:border-blue-200 transition-all" do %>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 group-hover:bg-blue-100">
<i class="fas fa-images text-blue-600 group-hover:text-blue-700"></i>
</div>
</div>
<span class="text-sm font-medium text-slate-700 group-hover:text-slate-800">View All Posts</span>
<i class="fas fa-arrow-right ml-auto text-xs text-slate-400 group-hover:text-blue-500"></i>
<% end %>
<%= link_to visual_search_domain_posts_path, class: "group flex items-center justify-center gap-3 rounded-lg bg-slate-50 border border-slate-200 p-4 hover:bg-emerald-50 hover:border-emerald-200 transition-all" do %>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 group-hover:bg-emerald-100">
<i class="fas fa-search text-emerald-600 group-hover:text-emerald-700"></i>
</div>
<span class="text-sm font-medium text-slate-700 group-hover:text-slate-800">Search by Image</span>
<i class="fas fa-upload ml-auto text-xs text-slate-400 group-hover:text-emerald-500"></i>
<% end %>
</div>
<div class="text-sm">
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">New</span>
<div class="mt-1">
Try the <%= link_to "FurAffinity User Recommender",
furecs_user_script_path,
class: "blue-link" %> user script to discover similar artists and users!
</div>
</div>
<!-- Feature announcement -->
<div class="mx-auto mt-6 max-w-2xl rounded-lg border border-blue-100 bg-blue-50/50 p-4">
<div class="flex items-start gap-3">
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">New</span>
<div class="flex-1 text-sm text-slate-700">
Try the <%= link_to "FurAffinity User Recommender",
furecs_user_script_path,
class: "text-blue-700 underline decoration-blue-300 underline-offset-2 hover:decoration-blue-500 font-medium" %> user script to discover similar artists and users!
</div>
</div>
</div>
<!-- Contact Section -->
<div class="mx-auto max-w-2xl">
<div class="border-t border-slate-200 pt-8">
<div class="text-center">
<div class="mb-4">
<p class="text-sm text-slate-600">Have questions, suggestions, or found a bug?</p>
<p class="text-xs text-slate-500 mt-1">Get in touch with @DeltaNoises</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-6">
<%= link_to "https://t.me/DeltaNoises", target: "_blank", class: "group inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-50 border border-slate-200 rounded-lg hover:bg-slate-100 hover:text-slate-700 transition-all" do %>
<i class="fab fa-telegram text-blue-500 group-hover:text-blue-600"></i>
<span>Message on Telegram</span>
<% end %>
<%= link_to "https://bsky.app/profile/delta.refurrer.com", target: "_blank", class: "group inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-50 border border-slate-200 rounded-lg hover:bg-slate-100 hover:text-slate-700 transition-all" do %>
<i class="fas fa-cloud text-sky-500 group-hover:text-sky-600"></i>
<span>Follow on BlueSky</span>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
# typed: strict
# frozen_string_literal: true
#
class MigrateE621PostsToAux < ActiveRecord::Migration[7.2]
sig { void }
def change
create_aux_table :domain_posts, :e621 do |t|
t =
T.cast(t, ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition)
t.string :state, null: false
t.integer :e621_id, null: false
t.timestamp :scanned_post_favs_at
t.string :rating
t.jsonb :tags_array
t.jsonb :flags_array
t.jsonb :pools_array
t.jsonb :sources_array
t.jsonb :artists_array
t.timestamp :e621_updated_at
t.integer :parent_post_e621_id
t.references :last_index_page,
index: false,
foreign_key: {
to_table: :http_log_entries,
}
t.references :caused_by_entry,
index: false,
foreign_key: {
to_table: :http_log_entries,
}
t.references :scan_log_entry,
index: false,
foreign_key: {
to_table: :http_log_entries,
}
t.jsonb :index_page_ids
t.string :description
t.integer :score
t.integer :score_up
t.integer :score_down
t.integer :num_favorites
t.integer :num_comments
t.integer :change_seq
t.string :md5
t.jsonb :prev_md5s
t.string :scan_error
t.references :uploader_user,
index: false,
foreign_key: {
to_table: :domain_users_e621_aux,
primary_key: :base_table_id,
}
end
end
end

View File

@@ -0,0 +1,73 @@
# typed: strict
# frozen_string_literal: true
class MigrateE621PostDataToAux < ActiveRecord::Migration[7.2]
sig { void }
def up
execute <<~SQL
INSERT INTO domain_posts_e621_aux (
base_table_id,
state,
e621_id,
scanned_post_favs_at,
rating,
tags_array,
flags_array,
pools_array,
sources_array,
artists_array,
e621_updated_at,
parent_post_e621_id,
last_index_page_id,
caused_by_entry_id,
scan_log_entry_id,
index_page_ids,
description,
score,
score_up,
score_down,
num_favorites,
num_comments,
change_seq,
md5,
prev_md5s,
scan_error,
uploader_user_id
)
SELECT
id as base_table_id,
(json_attributes->>'state')::text as state,
(json_attributes->>'e621_id')::integer as e621_id,
(json_attributes->>'scanned_post_favs_at')::timestamp as scanned_post_favs_at,
(json_attributes->>'rating')::text as rating,
(json_attributes->>'tags_array')::jsonb as tags_array,
(json_attributes->>'flags_array')::jsonb as flags_array,
(json_attributes->>'pools_array')::jsonb as pools_array,
(json_attributes->>'sources_array')::jsonb as sources_array,
(json_attributes->>'artists_array')::jsonb as artists_array,
(json_attributes->>'e621_updated_at')::timestamp as e621_updated_at,
(json_attributes->>'parent_post_e621_id')::integer as parent_post_e621_id,
(json_attributes->>'last_index_page_id')::integer as last_index_page_id,
(json_attributes->>'caused_by_entry_id')::integer as caused_by_entry_id,
(json_attributes->>'scan_log_entry_id')::integer as scan_log_entry_id,
(json_attributes->>'index_page_ids')::jsonb as index_page_ids,
(json_attributes->>'description')::text as description,
(json_attributes->>'score')::integer as score,
(json_attributes->>'score_up')::integer as score_up,
(json_attributes->>'score_down')::integer as score_down,
(json_attributes->>'num_favorites')::integer as num_favorites,
(json_attributes->>'num_comments')::integer as num_comments,
(json_attributes->>'change_seq')::integer as change_seq,
(json_attributes->>'md5')::text as md5,
(json_attributes->>'prev_md5s')::jsonb as prev_md5s,
(json_attributes->>'scan_error')::text as scan_error,
(json_attributes->>'uploader_user_id')::integer as uploader_user_id
FROM domain_posts
WHERE type = 'Domain::Post::E621Post'
SQL
end
sig { void }
def down
end
end

View File

@@ -1336,6 +1336,60 @@ CREATE TABLE public.domain_posts (
);
--
-- Name: domain_posts_e621_aux; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_posts_e621_aux (
base_table_id bigint NOT NULL,
state character varying NOT NULL,
e621_id integer NOT NULL,
scanned_post_favs_at timestamp without time zone,
rating character varying,
tags_array jsonb,
flags_array jsonb,
pools_array jsonb,
sources_array jsonb,
artists_array jsonb,
e621_updated_at timestamp without time zone,
parent_post_e621_id integer,
last_index_page_id bigint,
caused_by_entry_id bigint,
scan_log_entry_id bigint,
index_page_ids jsonb,
description character varying,
score integer,
score_up integer,
score_down integer,
num_favorites integer,
num_comments integer,
change_seq integer,
md5 character varying,
prev_md5s jsonb,
scan_error character varying,
uploader_user_id bigint
);
--
-- Name: domain_posts_e621_aux_base_table_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_posts_e621_aux_base_table_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_posts_e621_aux_base_table_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_posts_e621_aux_base_table_id_seq OWNED BY public.domain_posts_e621_aux.base_table_id;
--
-- Name: domain_posts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
@@ -2759,6 +2813,13 @@ ALTER TABLE ONLY public.domain_post_groups ALTER COLUMN id SET DEFAULT nextval('
ALTER TABLE ONLY public.domain_posts ALTER COLUMN id SET DEFAULT nextval('public.domain_posts_id_seq'::regclass);
--
-- Name: domain_posts_e621_aux base_table_id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux ALTER COLUMN base_table_id SET DEFAULT nextval('public.domain_posts_e621_aux_base_table_id_seq'::regclass);
--
-- Name: domain_twitter_tweets id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -2948,6 +3009,14 @@ ALTER TABLE ONLY public.domain_post_groups
ADD CONSTRAINT domain_post_groups_pkey PRIMARY KEY (id);
--
-- Name: domain_posts_e621_aux domain_posts_e621_aux_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT domain_posts_e621_aux_pkey PRIMARY KEY (base_table_id);
--
-- Name: domain_posts domain_posts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -3905,6 +3974,13 @@ CREATE INDEX index_domain_post_group_joins_on_type ON public.domain_post_group_j
CREATE INDEX index_domain_post_groups_on_type ON public.domain_post_groups USING btree (type);
--
-- Name: index_domain_posts_e621_aux_on_base_table_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_posts_e621_aux_on_base_table_id ON public.domain_posts_e621_aux USING btree (base_table_id);
--
-- Name: index_domain_posts_on_posted_at; Type: INDEX; Schema: public; Owner: -
--
@@ -5022,6 +5098,22 @@ ALTER TABLE ONLY public.domain_twitter_medias
ADD CONSTRAINT fk_rails_5fffa41fa6 FOREIGN KEY (file_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_posts_e621_aux fk_rails_73ac068c64; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_73ac068c64 FOREIGN KEY (uploader_user_id) REFERENCES public.domain_users_e621_aux(base_table_id);
--
-- Name: domain_posts_e621_aux fk_rails_7deb1f0178; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_7deb1f0178 FOREIGN KEY (scan_log_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_users_fa_aux fk_rails_7e51f8bfbc; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5062,6 +5154,22 @@ ALTER TABLE ONLY public.domain_user_post_creations
ADD CONSTRAINT fk_rails_9f4b85bc57 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
--
-- Name: domain_posts_e621_aux fk_rails_a90522803d; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_a90522803d FOREIGN KEY (last_index_page_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_posts_e621_aux fk_rails_ae368c64c2; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_ae368c64c2 FOREIGN KEY (base_table_id) REFERENCES public.domain_posts(id);
--
-- Name: domain_user_user_follows fk_rails_b45e6e3979; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5142,6 +5250,14 @@ ALTER TABLE ONLY public.domain_post_files
ADD CONSTRAINT fk_rails_d059c07f77 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_posts_e621_aux fk_rails_d691739802; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_d691739802 FOREIGN KEY (caused_by_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_post_group_joins fk_rails_eddd0a9390; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5181,6 +5297,8 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250723194407'),
('20250723193659'),
('20250722235434'),
('20250722153048'),
('20250722152949'),

View File

@@ -219,4 +219,81 @@ describe Domain::Fa::Job::ScanPostJob do
expect(post.state).to eq("removed")
end
end
describe "#uri_same_with_normalized_facdn_host?" do
let(:client_mock_config) { [] }
shared_examples "has result" do |result|
it "is #{result.to_s}, both have schema" do
url1 = "https://#{host1}#{path1}"
url2 = "https://#{host2}#{path2}"
expect(
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
).to eq(result)
end
it "is #{result.to_s}, both missing schema" do
url1 = "//#{host1}#{path1}"
url2 = "//#{host2}#{path2}"
expect(
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
).to eq(result)
end
it "is #{result.to_s}, one has schema" do
url1 = "https://#{host1}#{path1}"
url2 = "//#{host2}#{path2}"
expect(
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
).to eq(result)
end
end
shared_context "host: different cdn hosts" do
let(:host1) { "d.facdn.net" }
let(:host2) { "d.furaffinity.net" }
end
shared_context "host: both hosts are d.facdn.net" do
let(:host1) { "d.facdn.net" }
let(:host2) { "d.facdn.net" }
end
shared_context "host: both hosts are d.furaffinity.net" do
let(:host1) { "d.furaffinity.net" }
let(:host2) { "d.furaffinity.net" }
end
shared_context "host: one domain is not a cdn" do
let(:host1) { "d.facdn.net" }
let(:host2) { "example.com" }
end
shared_context "paths: are the same" do
let(:path1) { "/art/user/1234567890/image.jpg" }
let(:path2) { "/art/user/1234567890/image.jpg" }
end
shared_context "paths: are different" do
let(:path1) { "/art/user/1234567890/image.jpg" }
let(:path2) { "/art/user/1234567890/some_other_image.jpg" }
end
[
["host: different cdn hosts", "paths: are the same", true],
["host: both hosts are d.facdn.net", "paths: are the same", true],
["host: both hosts are d.furaffinity.net", "paths: are the same", true],
["host: one domain is not a cdn", "paths: are the same", false],
["host: different cdn hosts", "paths: are different", false],
["host: both hosts are d.facdn.net", "paths: are different", false],
["host: both hosts are d.furaffinity.net", "paths: are different", false],
["host: one domain is not a cdn", "paths: are different", false],
].each do |host_context, path_context, result|
context "#{host_context} and #{path_context}" do
include_context host_context
include_context path_context
include_examples "has result", result
end
end
end
end