diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..99b6407 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +node_modules +.next +.wrangler +.sites-runtime +dist +work +outputs +data +.env* +*.sqlite* +*.tsbuildinfo +components +hooks +examples +db +drizzle +scripts +worker +vendor +.openai +app/chatgpt-auth.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f02c096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Next.js / Node +node_modules/ +.next/ +next-env.d.ts +*.tsbuildinfo +# Persistent runtime data and local state +data/ +*.sqlite* +.env* +.wrangler/ +.sites-runtime/ +.vinext/ +dist/ +work/ +outputs/ +# Retained original Sites scaffold (not part of Node deployment) +components/ +hooks/ +examples/ +db/ +drizzle/ +scripts/ +worker/ +vendor/ +.openai/ +vite.config.ts +drizzle.config.ts +cloudflare-env.d.ts +components.json +eslint.config.mjs +lib/utils.ts +lib/connectors.ts +lib/connector-preview.d.ts +lib/connector-errors.mts +lib/connector-contract.mts +lib/connector-context.ts +app/chatgpt-auth.ts +# OS +.DS_Store +AGENTS.md +CLAUDE.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9f274d6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..347e14c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +## 1.2.0 — 2026-10-29 + +- Хранилище перенесено на PostgreSQL для использования существующего сервера VPS; приложению предназначены отдельная база и роль. +- Добавлено общее редактируемое расписание: ежедневная или еженедельная проверка по московскому времени, выбор режима кэша, включение и отключение. +- Проверки по расписанию выполняются на сервере при закрытом браузере и возобновляются после перезапуска. +- Следующий запуск, пропуск совпавшего запуска и ошибки фоновой проверки отображаются в интерфейсе. +- Расписание и запуск сохраняются атомарно; совпавшие запуски не дублируются, после простоя выполняется один пропущенный запуск. +- При старте приложения одновременно запускаются веб-сервер и worker с общим завершением при сбое одного процесса. + +## 1.1.1 — 2026-10-29 + +- Ответы 302/304 без материала, включая бинарный Content-Type, отмечаются как требующие ручной проверки. + +## 1.1.0 — 2026-10-29 + +- Перенесён сервер с Cloudflare Workers/D1 на Node.js24 и постоянную SQLite для VPS. +- Добавлен путь `/courses` для интерфейса, API и шрифтов; production Origin настроен для miem.space. +- DNS-адрес закрепляется в сетевом соединении, включая повторную проверку перенаправлений. +- Добавлены standalone-сборка, непривилегированный Docker runtime, постоянный volume, healthcheck и Nginx-сниппет. +- Сохранение порций и очереди атомарно; добавлены проверки восстановления после ошибки записи, блокировки одновременных порций и Origin за reverse proxy. + +## 1.0.1 — 2026-10-29 + +- Добавлен общий реестр доступности литературы пяти программ НИУ ВШЭ без регистрации. +- Ручной запуск с продолжением, обход пагинации, чтение основной и дополнительной литературы, включая текстовые URL. +- Сохранение результатов и контрольных сумм курсов; повторное использование неизменившихся курсов и полная проверка без кэша. +- Отдельные причины недоступности, переходы к материалам и исходным курсам, поиск, фильтры и экспорт CSV. +- Явные отметки неполного охвата, JavaScript-заглушек, ограничений доступа и устаревших результатов. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..02f5a35 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,2 @@ +# Визуальная система +Режим Operate. Академический реестр: тёплая бумага, глубокий зелёный, киноварь для проблем. Боковая навигация по программам, заголовок с засечками, плотный список результатов. Состояния подписаны словами. Без иллюстративных статистик и декоративных карточек. Адаптивность, клавиатура, заметный фокус, reduced motion. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..98f4ea3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm install --global pnpm@11.25.0 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY app ./app +COPY lib/model.ts lib/checker.ts lib/scanner.ts lib/store.ts lib/scheduler.ts lib/schedule-worker.ts ./lib/ +COPY public ./public +COPY next.config.ts tsconfig.json postcss.config.mjs ./ +RUN pnpm build + +FROM node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c AS runtime +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000 +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +COPY --from=build --chown=node:node /app/public ./public +COPY --from=build --chown=node:node /app/lib ./lib +COPY server ./server +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/courses/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node","server/runtime.mjs","--standalone"] diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..d061b77 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,6 @@ +# Сверка +Общий реестр доступности литературы пяти программ НИУ ВШЭ. Без входа. Только реальные результаты; начальное состояние пустое. Node.js24 и PostgreSQL хранят результаты, контрольные суммы курсов и прогресс в постоянном volume. Проверка порциями, пока открыта вкладка; продолжение вручную. Неизменившиеся курсы используют кэш; доступна полная проверка. Успешный HTTP-ответ не доказывает доступ к полному тексту. Целевой путь — https://miem.space/courses. + +Расписание: одно общее, ежедневно или еженедельно по Москве, выключено до сохранения пользователем. Запланированный аудит работает фоновым процессом при закрытом браузере и продолжается после перезапуска. Совпавший слот пропускается, если предыдущая ручная или автоматическая проверка ещё running. Изменение/отключение влияет на будущие запуски. + +Для VPS используется отдельная база/роль на существующем PostgreSQL. Пароль только в окружении, не в исходниках. diff --git a/README.md b/README.md index e69de29..65d0ab3 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,53 @@ +# Сверка + +Проверка доступности литературы пяти образовательных программ НИУ ВШЭ. Целевой адрес: `https://miem.space/courses`. Авторизация не требуется. + +## Запуск + +Требуется Node.js24 и pnpm11.25.0. + +```sh +pnpm install --frozen-lockfile +TEST_DATABASE_URL=<изолированная-локальная-БД-sverka_test> pnpm test +pnpm typecheck +pnpm dev +``` + +Открыть `http://127.0.0.1:5173/courses`. Для сервера требуется `DATABASE_URL` с подключением к отдельной базе PostgreSQL. Там хранятся результаты, очередь и расписание; новое пустое хранилище инициализируется при первом подключении. Пароль передаётся только через окружение или защищённый файл окружения вне Git. `COURSES_ORIGIN` задаёт допустимый Origin для POST за reverse proxy, в production — `https://miem.space`. + +Ручная проверка выполняется порциями, пока вкладка открыта. После её закрытия или перезапуска сервера кнопка «Продолжить» использует сохранённую очередь. Глобальная аренда не допускает одновременную обработку одной порции. Каждая порция сохраняет результаты и очередь в одной транзакции PostgreSQL; при ошибке записи задания остаются в очереди. + +Страницы курсов загружаются заново, но литература неизменившихся курсов берётся из кэша вместе с исходной датой проверки. Полная проверка без кэша нужна для проверки изменений на внешних сайтах. HTTP200 не доказывает доступ к полному тексту; JavaScript-заглушки, CAPTCHA и признаки подписки отмечаются отдельно. Литература только во вложении отмечается как неполный охват. + +## Расписание + +В интерфейсе откройте «Проверка по расписанию»: включите автоматический запуск, выберите ежедневное или еженедельное повторение, время по Москве, при необходимости день недели и режим кэша. Сохранение и отключение доступны там же. По умолчанию расписание выключено; оно общее для всех посетителей и хранится в той же базе PostgreSQL. + +`pnpm dev`, `pnpm start` и Docker запускают веб-сервер и отдельный фоновый процесс одним управляемым runtime. Проверка по расписанию выполняется без открытой вкладки, при перезапуске продолжается по сохранённой очереди. Расписание проверяется раз в пять секунд. Ручные проверки сохраняют прежний режим с открытой вкладкой. + +Если любая предыдущая проверка ещё имеет статус running, совпавший слот пропускается с отметкой в интерфейсе: параллельных запусков нет. После простоя сервера выполняется один просроченный запуск; пропущенные дни не образуют очередь. Изменение или отключение расписания не прерывает уже начатую автоматическую проверку. Если один из двух процессов runtime завершится, второй останавливается; restart policy Docker поднимает их вместе. + +## Production + +```sh +docker compose build +docker compose up -d +``` + +Эти команды изменяют production: выполнять только через разрешённый deployment-процесс после независимого review. Сервис слушает только `127.0.0.1:3011`, работает от UID1000 и использует существующий сервер PostgreSQL через внешнюю Docker-сеть `miem_workers_default`. Отдельный контейнер БД или SQLite volume не создаётся. Healthcheck: `GET /courses/api/health`. Порт3011 — предлагаемый; перед запуском проверить, что он свободен. Код не зависит от Cloudflare Workers, D1 или Sites hosting. + +Добавить `deploy/nginx-courses.conf` в существующий TLS-server `miem.space`, проверить `nginx -t`, затем выполнить разрешённый reload. Конфиг передаёт исходный URI без удаления `/courses`; `/courses-other` не захватывается. Фактический путь Nginx-конфига должен быть подтверждён read-only preflight, а не угадан. + +## Порядок первого deployment + +1. Подтвердить актуальные branch/head, независимое review, тесты, сборку, Docker daemon/диск, свободный порт и отсутствие существующей локации `/courses`. +2. Сохранить текущую конфигурацию Nginx. Использовать отдельную базу `miem_courses_links` и отдельную LOGIN-роль без SUPERUSER/CREATEDB/CREATEROLE. Не использовать учётную запись и таблицы существующих приложений. Перед изменением уже заполненной базы сделать pg_dump и проверить возможность восстановления; на первом размещении новая база пустая. +3. Собрать exact commit, запустить только новый сервис. Проверить localhost healthcheck и `/courses`, статические файлы и шрифты. +4. Включить Nginx location, выполнить syntax check/reload через разрешённый канал. Проверить HTTPS страницу, healthcheck и ручной шаг проверки, затем перезапуск сервиса с сохранением данных. +5. При неудаче вернуть прежний Nginx-конфиг, проверить и reload; остановить только новый сервис. Базу и роль не удалять. При обновлении вернуть предыдущий совместимый image tag, сохранив PostgreSQL. Не применять `compose down -v` или общий prune. + +Обновление Nginx обычно не требует остановки существующих сервисов; фактический риск и разрешённый способ reload определяются инфраструктурным runbook. DATABASE_URL передаётся Docker Compose из окружения либо через защищённый deployment env-file; его содержимое не публикуется и не коммитится. В этом checkout production deployment не выполнен. + +## Проверки + +`TEST_DATABASE_URL` должен вести только на локальный `sverka_test`; DB-тесты создают и удаляют собственные случайные схемы и отказываются работать с другой базой. `pnpm test` проверяет извлечение ссылок, классификацию, URL со скобками, запрет непубличных IP, атомарный rollback/replay очереди, единственный общий запуск, lease, создание/изменение/отключение расписания, московское время и переходы календаря, однократный claim, skip пересекающихся запусков и выполнение worker без браузера. Node HTTP-загрузчик закрепляет проверенный DNS-адрес в сокете и проверяет каждый redirect; TLS проверяет исходное имя хоста. Прокси из environment не используются загрузчиком. diff --git a/app/api/audit/route.ts b/app/api/audit/route.ts new file mode 100644 index 0000000..d2c770a --- /dev/null +++ b/app/api/audit/route.ts @@ -0,0 +1,15 @@ +import {dashboard,start,step} from '../../../lib/scanner.ts'; +import {saveSchedule} from '../../../lib/scheduler.ts'; +export const dynamic='force-dynamic'; +export const runtime='nodejs'; +export async function GET(){try{return Response.json(await dashboard(),{headers:{'Cache-Control':'no-store'}});}catch{return Response.json({error:'Не удалось прочитать результаты. Попробуйте обновить страницу.'},{status:503});}} +export async function POST(request:Request){ +const origin=request.headers.get('origin'); +// Production uses a fixed public origin. Next's internal URL may use localhost +// during development even when the browser connected to 127.0.0.1. +const host=request.headers.get('host'); +const expectedOrigin=process.env.COURSES_ORIGIN??(process.env.NODE_ENV!=='production'&&host?new URL(request.url).protocol+'//'+host:null); +if(!origin||!expectedOrigin||origin!==expectedOrigin)return Response.json({error:'Запрос с другого сайта запрещён'},{status:403}); +try{const body=await request.json() as {action?:string;force?:boolean;schedule?:unknown};if(body.action==='schedule'){ + try{return Response.json(await saveSchedule(body.schedule));}catch(error){if(error instanceof RangeError)return Response.json({error:error.message},{status:400});throw error;} +}if(body.action==='start')return Response.json(await start(body.force===true));if(body.action==='step')return Response.json(await step());return Response.json({error:'Неизвестное действие'},{status:400});}catch{return Response.json({error:'Не удалось выполнить шаг. Сохранённый прогресс можно продолжить.'},{status:503});}} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..2db5b83 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,5 @@ +import {database} from '../../../lib/store'; +import {VERSION} from '../../../lib/model'; +export const dynamic='force-dynamic'; +export const runtime='nodejs'; +export async function GET(){try{await (await database()).query('SELECT 1');return Response.json({status:'ok',version:VERSION},{headers:{'Cache-Control':'no-store'}});}catch{return Response.json({status:'error'},{status:503});}} diff --git a/app/dashboard.tsx b/app/dashboard.tsx new file mode 100644 index 0000000..6c42451 --- /dev/null +++ b/app/dashboard.tsx @@ -0,0 +1,35 @@ +'use client'; +import {useEffect,useRef,useState} from 'react'; +import {ArrowUpRight,Download,BookOpen,Check,ChevronDown,ExternalLink,FileSearch,Link2,LoaderCircle,Pause,Play,Search,ShieldCheck,SlidersHorizontal,X,RefreshCw} from 'lucide-react'; +import SchedulePanel from './schedule-panel'; +import {PROGRAMS,LABELS,VERSION,DEFAULT_SCHEDULE,type Dashboard,type Run} from '../lib/model'; +const date=(v?:string)=>v?new Date(v).toLocaleString('ru-RU',{timeZone:'Europe/Moscow',day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}):'Ещё не проверено'; +export default function Audit(){ +const [data,setData]=useState({run:null,courses:[],checks:{},programs:PROGRAMS,schedule:{...DEFAULT_SCHEDULE}}),[loaded,setLoaded]=useState(false); +const [loading,setLoading]=useState(true),[active,setActive]=useState(false),[error,setError]=useState(''),[program,setProgram]=useState('all'),[query,setQuery]=useState(''),[status,setStatus]=useState('issues'),[view,setView]=useState('links'),[force,setForce]=useState(false),[expanded,setExpanded]=useState(''),[limit,setLimit]=useState(40); +const running=useRef(false),mounted=useRef(true); +async function refresh(){const r=await fetch('/courses/api/audit',{cache:'no-store'}),d=await r.json() as Dashboard & {error?:string};if(!r.ok)throw new Error(d.error);if(mounted.current){setData(d);setLoaded(true);}} +useEffect(()=>{mounted.current=true;refresh().catch(e=>setError(e.message)).finally(()=>setLoading(false));return()=>{mounted.current=false;running.current=false;};},[]); +async function post(action:string){const r=await fetch('/courses/api/audit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,force})}),d=await r.json() as Run & {error?:string};if(!r.ok)throw new Error(d.error);return d as Run;} +async function launch(){if(running.current)return;running.current=true;setActive(true);setError('');try{let run=data.run?.status==='running'?data.run:await post('start');setData(d=>({...d,run}));while(running.current&&run?.status==='running'){run=await post('step');if(!mounted.current)break;setData(d=>({...d,run}));await refresh();await new Promise(r=>setTimeout(r,350));}}catch(e){setError((e as Error).message);}finally{running.current=false;if(mounted.current){setActive(false);refresh().catch(()=>{});}}} +function pause(){running.current=false;} +const serverActive=data.run?.source==='schedule'&&data.run.status==='running'; +useEffect(()=>{if(active||(!data.schedule.enabled&&!serverActive))return;const timer=setInterval(()=>{refresh().catch(e=>setError(e.message));},3000);return()=>clearInterval(timer);},[active,data.schedule.enabled,serverActive]); +const run=data.run,courses=data.courses.filter(c=>program==='all'||c.program===program); +const rows=courses.flatMap(course=>course.refs.map(ref=>({course,ref,result:data.checks[ref.url]}))),problems=rows.filter(r=>r.result&&r.result.status!=='ok'); +const visible=rows.filter(r=>(status==='all'||status==='issues'&&r.result&&r.result.status!=='ok'||status==='pending'&&!r.result||r.result?.status===status)&&(!query||(r.ref.title+' '+r.ref.url+' '+r.course.name).toLowerCase().includes(query.toLowerCase()))); +const shownCourses=courses.filter(c=>!query||(c.name+' '+c.url).toLowerCase().includes(query.toLowerCase())); +useEffect(()=>setLimit(40),[query,status,program,view]); +function exportCsv(){const rows=[['Программа','Курс','Ссылка на курс','Литература','URL','Статус','Причина','Проверено'],...visible.map(r=>[PROGRAMS.find(p=>p.id===r.course.program)?.name??'',r.course.name,r.course.url,r.ref.title,r.ref.url,r.result?LABELS[r.result.status]:'Не проверено',r.result?.reason??'',r.result?.checkedAt??''])];const csv='\uFEFF'+rows.map(row=>row.map(v=>'"'+String(v).replace(/^[=+@-]/,"'$&").replaceAll('"','""')+'"').join(';')).join('\r\n');const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));a.download='sverka-links.csv';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);} +const selected=PROGRAMS.find(p=>p.id===program),percent=run?.phase==='links'?Math.round(run.checked/Math.max(1,run.totalLinks)*100):run?.phase==='courses'?Math.round(run.processed/Math.max(1,run.totalCourses)*100):0; +return
К результатам
НИУ ВШЭ / Учебная литература

{view==='method'?'Как устроена сверка':view==='courses'?'Учебные курсы':'Ссылки под наблюдением'}

{view==='method'?'Что проверяется и как читать результаты.':'Проверяйте литературу. Находите недоступные материалы.'}

5 программединый реестр
+{error&&
{error}
} +{view==='method'?

От программы — к материалу

Сверка читает текущий учебный год на пяти страницах ВШЭ, обходит пагинацию и открывает каждый курс. Из разделов основной и дополнительной литературы извлекаются кликабельные ссылки и адреса, записанные обычным текстом.

Вы управляете проверкой

Нажмите «Запустить проверку» и оставьте вкладку открытой. Можно приостановить проверку, закрыть вкладку и позже продолжить сохранённый запуск. Одновременно выполняется один общий запуск. Приостановка в этой вкладке не останавливает проверку у другого посетителя.

Проверка по расписанию

Настройте ежедневный или еженедельный запуск и время по Москве. Расписание общее для всех посетителей и сохраняется на сервере. Автоматическая проверка работает без браузера. Изменение или отключение расписания влияет на будущие запуски; уже начатая проверка продолжается. Если другая проверка ещё идёт, совпавший запуск пропускается. После простоя сервера выполняется один просроченный запуск, без очереди пропущенных повторений.

Изменения вместо повторений

Страницы курсов загружаются и сравниваются по контрольной сумме. Неизменившиеся курсы сохраняют результаты и исходные даты проверки ссылок. Одинаковые адреса в изменившихся курсах проверяются один раз за запуск. Полная проверка игнорирует кэш: используйте её, чтобы обнаружить изменения на внешних сайтах при неизменной программе курса.

Успешный ответ — ещё не полный текст

Различаются 404/410, вход, ограничения доступа, CAPTCHA, ошибки сервера и тайм-ауты. «Доступна» означает успешный ответ сервера. Карточка книги может открываться, а текст — требовать подписку. Ограничения определяются по признакам на странице и требуют ручного подтверждения. Сверка не обходит вход и защиту сайта.

Границы проверки

Проверяется литература в HTML-программах. Если раздел отсутствует, доступен только во вложении или не читается, курс получает отдельную отметку. Архивные годы и навигационные ссылки не проверяются. Старые результаты сохраняются с датами; непрочитанные и исчезнувшие курсы отмечаются как не обновлённые.

:<> +

{serverActive?'Выполняется проверка по расписанию':active?'Сверяем источники':run?.status==='running'?'Проверка ожидает продолжения':run?'Результаты сохранены':'Начните с первой проверки'}

{active||serverActive?run?.message:run?`Последний запуск: ${date(run.startedAt)} · МСК`:'Все курсы пяти программ. Только реальные результаты.'}

{serverActive?:active?:}
{run?.status==='running'&&
{run.phase==='catalogs'?`Каталоги: ${run.visited.length}`:run.phase==='courses'?`Курсы: ${run.processed} / ${run.totalCourses}`:`Адреса: ${run.checked} / ${run.totalLinks}`}{serverActive?'Выполняется без открытой вкладки':'Оставьте вкладку открытой'}
}
+{loaded?setData(d=>({...d,schedule}))}/>:

{loading?'Загружаем настройки расписания…':'Расписание недоступно. Повторите загрузку через сообщение об ошибке выше.'}

}
{problems.length||'—'}ссылок требуют внимания
{courses.length||'—'}курсов в реестре
{rows.filter(r=>r.result?.status==='ok').length||'—'}ссылок доступны
{run?.reused??'—'}курсов взято из кэша
+{run&&run.errors.length>0&&
Не удалось прочитать {run.errors.length} источников — охват неполный{run.errors.map((e,i)=>

{e.url}
{e.reason}

)}
} +

{view==='courses'?'Каталог курсов':'Результаты проверки'}{view==='courses'?shownCourses.length:visible.length}

{view==='links'&&}
{selected&&
{selected.name}Каталог ВШЭ
} +{loading?

Загружаем реестр

:view==='courses'?shownCourses.length?
{shownCourses.slice(0,limit).map(c=>
{c.name}

{PROGRAMS.find(p=>p.id===c.program)?.name}

{c.issue&&

{c.issue}

}{date(c.checkedAt)} · {c.refs.length} адресов {c.reused?'· из кэша':''}{run&&c.seen!==run.id?' · не обновлён в текущем запуске':''}
{c.issue?'Нужна проверка':c.refs.length?'Литература найдена':'Нет ссылок'}
)}
::visible.length?<>
Материал и источникРезультатПроверено, МСК
{visible.slice(0,limit).map(r=>{const key=r.course.url+r.ref.url;return
{r.ref.title||r.ref.url}
{r.ref.url}
{r.course.name}{PROGRAMS.find(p=>p.id===r.course.program)?.name}{run&&r.course.seen!==run.id?' · прежний результат':''}
{r.result?LABELS[r.result.status]:'Не проверено'}
{date(r.result?.checkedAt)}{r.result?.code&&HTTP {r.result.code}}
{expanded===key&&

{r.result?.reason??'Адрес ещё не проверен. Запустите или продолжите сверку.'}

{r.result?.finalUrl&&r.result.finalUrl!==r.ref.url&&После перенаправления: {r.result.finalUrl}}{r.course.reused&&Содержание курса не изменилось. Использован сохранённый результат.}
}
;})}
:} +{(view==='courses'?shownCourses.length:visible.length)>limit&&}
Блокировка проверки не означает, что материал удалён. Причину можно уточнить по исходной ссылке.
Проверяем изменения, сохраняем результаты.Неизменившиеся курсы — из кэша
} +
сверка. Независимый инструмент · источники: hse.ruИнтерфейс {VERSION} · Сервер {VERSION}
;} +function Empty({hasRun,filtered,launch}:{hasRun:boolean;filtered:boolean;launch:()=>void}){return

{filtered?'По этим условиям ничего нет':hasRun?'Проблемных ссылок пока нет':'Пока ни одна ссылка не проверена'}

{filtered?'Измените запрос, причину или выбранную программу.':hasRun?'Смотрите охват и список курсов. Непрочитанные источники отмечаются отдельно.':'Запустите сверку, чтобы найти недоступную литературу и увидеть, в каких курсах она используется.'}

{!hasRun&&!filtered&&}
404 / удаленаТребуется входОграничен доступ
;} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8ddff7b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,8 @@ +@import url('/courses/fonts/fonts.css'); +:root{--paper:#f7f7f2;--white:#fff;--ink:#243a32;--muted:#69756d;--forest:#203e32;--line:#dfe3da;--red:#a54735;--accent:#d9ebbb;--sans:'Golos Text',sans-serif;--serif:'Lora',Georgia,serif} +*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer;color:inherit}button:disabled{opacity:.45;cursor:default}a{color:inherit;text-decoration:none;text-underline-offset:4px}a:hover{text-decoration:underline}button{border:0;background:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #85a457;outline-offset:4px}::selection{background:#d9ebbb;color:#203e32}input{caret-color:var(--forest)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:#b7c2b5;border-radius:8px}svg{flex-shrink:0;vertical-align:middle}.shell{min-height:100vh;display:grid;grid-template-columns:275px minmax(0,1fr)}.sidebar{background:var(--forest);color:#f2f5ed;padding:38px 21px 24px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;font-size:32px;font-weight:600;letter-spacing:-1.3px;padding:0 10px;line-height:1}.brand:hover{text-decoration:none}.brand__mark{border:1px solid #b9ccad;width:38px;height:38px;display:grid;place-items:center;border-radius:50%;margin-right:11px;color:var(--accent)}.brand__dot{color:var(--accent)}.sidebar__description{color:#c1cfbf;font-size:12px;line-height:1.7;margin:24px 10px 39px}.primary-nav{display:grid;gap:6px}.primary-nav button{display:flex;align-items:center;text-align:left;gap:11px;padding:13px 11px;font-size:12px;border-radius:5px;color:#d6dfd1}.primary-nav button.selected{background:#3d5743;color:#f6f9f0}.primary-nav button:hover,.program-nav button:hover{background:#35513e}.primary-nav button span{margin-left:auto;background:#50664c;color:#e0edce;border-radius:3px;padding:0 6px;font-size:11px}.sidebar__label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#b6c6af;margin:37px 10px 14px}.program-nav{display:grid;gap:3px}.program-nav button{text-align:left;color:#d9e3d3;display:flex;align-items:flex-start;justify-content:space-between;gap:9px;font-size:12px;line-height:1.55;padding:12px 11px;border-radius:5px}.program-nav button.selected{background:#d9ebbb;color:#223c2e}.program-nav small{display:block;font-size:10px;opacity:.75;margin-top:4px}.program-nav svg{margin-top:2px}.program-nav b{font-weight:500}.sidebar__bottom{border-top:1px solid #4b604a;display:flex;gap:12px;align-items:center;margin: auto 10px 0;padding-top:20px;color:#c5d5b9}.sidebar__bottom p{font-size:11px;line-height:1.7;margin:0}.sidebar__bottom span{color:#b2c1a9}.workspace{min-width:0}.topbar{height:76px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 44px;font-size:11px}.topbar>span{font-weight:500}.topbar__divider{color:#a7b3a2;margin:0 14px}.topbar button{display:flex;gap:10px;align-items:center;color:var(--muted);font-size:11px}main{max-width:1530px;margin:0 auto;padding:44px 44px 0}.page-title{display:flex;justify-content:space-between;gap:20px;align-items:center;margin:0 0 32px}h1{font-family:var(--serif);font-weight:400;font-size:clamp(28px,3vw,43px);line-height:1.2;letter-spacing:-1.3px;margin:0 0 12px}.page-title p{margin:0;color:var(--muted);font-size:12px}.edition{font-size:12px;white-space:nowrap;text-align:right;padding-left:22px;border-left:1px solid var(--line)}.edition span{display:block;color:var(--muted);font-size:10px;margin-top:4px}.control{border:1px solid #ced8c2;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:17px;background:#eef2e7;padding:26px 25px;border-radius:7px}.control__icon{width:47px;height:47px;display:grid;place-items:center;background:#e0e8d5;border-radius:50%;color:#4b6340}.control h2{font-size:15px;margin:0 0 6px;font-weight:550}.control p{font-size:11px;color:#5f7058;margin:0;overflow-wrap:anywhere}.control__actions{display:grid;gap:10px;justify-items:center}.button{display:inline-flex;align-items:center;justify-content:center;gap:9px;border-radius:5px;padding:12px 16px;font-size:12px;font-weight:500;white-space:nowrap;transition:background .15s}.button--primary{background:var(--forest);color:#f4f7ef}.button--primary:hover{background:#345b42}.force{display:flex;align-items:center;font-size:10px;color:#596950;gap:6px;cursor:pointer}.force input{accent-color:#34553a;width:12px;height:12px;margin:0}.scan-progress{grid-column:1/-1;border-top:1px solid #d4dfcb;padding-top:14px;font-size:11px;color:#536a48}.scan-progress>div{display:flex;justify-content:space-between;gap:10px}progress{width:100%;height:5px;accent-color:var(--forest);margin-top:10px;display:block}.summary{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr;padding:30px 0 28px;border-bottom:1px solid var(--line);gap:18px;margin-bottom:34px}.summary>div{display:flex;flex-direction:column;padding-left:24px;border-left:1px solid var(--line)}.summary>div:first-child{padding-left:0;border:0}.summary__number{font-size:31px;font-weight:450;letter-spacing:-1px;line-height:1.2;font-variant-numeric:tabular-nums}.summary__number--problem{color:var(--red)}.summary>div>span:last-child{font-size:10px;color:var(--muted);margin-top:8px}.results{border:1px solid var(--line);border-radius:6px;background:white;overflow:hidden}.results__heading{display:flex;justify-content:space-between;align-items:center;padding:23px 23px 18px;gap:15px}.results__heading h2{display:flex;align-items:center;gap:10px;margin:0;font-size:17px;font-weight:550}.results__heading h2 span{color:var(--muted);background:var(--paper);border:1px solid var(--line);padding:0 6px;border-radius:4px;font-size:10px;font-weight:400}.text-button{display:inline-flex;gap:8px;align-items:center;color:#47633c;font-size:11px;padding:4px}.text-button:hover{color:#172d21;text-decoration:underline;text-underline-offset:4px}.filters{display:flex;gap:12px;padding:0 23px 20px}.search{display:flex;align-items:center;gap:10px;border:1px solid var(--line);background:#fbfcf9;border-radius:4px;padding:10px 12px;flex:1;min-width:0;color:#82907b}.search input{min-width:0;width:100%;border:0;background:transparent;outline:none;color:var(--ink);font-size:11px}.search:focus-within{outline:2px solid #85a457;outline-offset:2px}.search input::placeholder{color:#6e7c65}.search button{padding:0}.status-select{display:flex;align-items:center;gap:7px;border:1px solid var(--line);padding:0 10px;border-radius:4px;font-size:11px}.status-select select{appearance:none;border:0;max-width:200px;background:transparent;color:var(--ink);cursor:pointer;font-size:11px;padding:11px 0}.table-head,.link-row{display:grid;grid-template-columns:minmax(0,1fr) 155px 112px;gap:15px}.table-head{padding:11px 23px;font-size:10px;color:var(--muted);background:#f5f7f1;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.link-row{padding:20px 23px;border-bottom:1px solid #e7ebe3;align-items:start}.link-row:hover{background:#fcfdf9}.link-row__main{display:flex;gap:12px;min-width:0}.link-row__main>div{min-width:0}.status-dot{width:7px;height:7px;border-radius:50%;background:#8b9585;display:inline-block;flex-shrink:0}.link-row__main>.status-dot{margin-top:7px}.status-dot--broken{background:#b24e3c}.status-dot--auth{background:#ad7929}.status-dot--blocked,.status-dot--restricted{background:#9c6a43}.status-dot--network,.status-dot--timeout,.status-dot--unknown{background:#7b668e}.status-dot--ok{background:#4e7847}.material-name{font-size:12px;font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.65;overflow-wrap:anywhere}.material-name svg{margin-left:4px}.material-url{font-size:10px;color:#718068;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;margin:4px 0 10px}.source-course{font-size:10px;display:flex;gap:5px;align-items:center;color:#486640}.program-name{display:block;font-size:9px;color:var(--muted);margin-top:4px}.badge{font-size:9px;line-height:1.4;padding:5px 7px;border-radius:4px;display:inline-block;background:#f0f1ec;color:#5a6755;white-space:nowrap}.badge--broken{background:#f9e8e2;color:#9b402f}.badge--auth,.badge--restricted,.badge--blocked{background:#f6eddb;color:#80571c}.badge--network,.badge--timeout,.badge--unknown{background:#f0eaf4;color:#705080}.badge--ok{background:#eaf2df;color:#42602f}.reason-toggle{font-size:10px;display:flex;align-items:center;gap:5px;padding:7px 0;color:var(--muted)}.link-row__date{font-size:10px;color:#69765f;font-variant-numeric:tabular-nums}.link-row__date small{display:block;font-size:9px;color:#829079;margin-top:5px}.reason{grid-column:1/-1;padding:14px 18px;background:#f5f7f0;border-radius:4px;font-size:12px;overflow-wrap:anywhere}.reason p{margin:0}.reason a,.reason small{display:block;margin-top:8px;font-size:11px}.empty{min-height:300px;padding:36px 25px 27px;display:flex;flex-direction:column;align-items:center;text-align:center}.empty__drawing{position:relative;width:72px;height:72px;background:#f0f4e8;border-radius:50%;display:grid;place-items:center;color:#7a9563;margin-bottom:17px}.empty__drawing>span{position:absolute;bottom:3px;right:0;background:#dce9cc;color:#4e7037;display:grid;place-items:center;width:25px;height:25px;border:3px solid white;border-radius:50%}.empty h3{font-size:17px;font-weight:500;margin:0 0 9px}.empty p{font-size:11px;color:var(--muted);max-width:380px;line-height:1.8;margin:0 0 15px}.empty__legend{display:flex;flex-wrap:wrap;justify-content:center;gap:22px;margin-top:27px;color:#6b7764;font-size:9px}.empty__legend>span{display:flex;gap:6px;align-items:center}.results__note{border-top:1px solid var(--line);padding:13px 20px;display:flex;align-items:center;gap:8px;color:#6d7b63;background:#fafbf7;font-size:9px}.bottom-note{display:flex;justify-content:space-between;gap:15px;margin-top:20px;font-size:10px;color:#74816b}.bottom-note>span:last-child{display:flex;gap:7px;align-items:center}footer{display:flex;justify-content:space-between;gap:20px;align-items:center;border-top:1px solid var(--line);padding:22px 0;margin-top:60px;font-size:10px;color:#738068}footer>span:first-child{font-size:13px;font-weight:600;color:#4c6342}footer>span>span{font-size:9px;font-weight:400;color:#74816b;margin-left:12px}.error-banner{padding:15px;border:1px solid #e2b4a4;background:#fff1e9;display:flex;justify-content:space-between;gap:20px;font-size:12px;margin-bottom:20px;border-radius:5px;color:#873d2a}.error-banner button{text-decoration:underline}.source-errors{background:#fff4e0;padding:14px 18px;margin-bottom:20px;color:#825b25;font-size:12px;border-radius:5px}.source-errors summary{cursor:pointer}.source-errors p{font-size:11px;overflow-wrap:anywhere}.scope{padding:0 23px 18px;display:flex;align-items:center;gap:10px;color:#566f45;font-size:11px}.scope a{margin-left:auto;font-size:10px}.course-list article{padding:20px 23px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:20px}.course-list article>div{min-width:0}.course-name{font-size:13px;font-weight:500}.course-name svg{margin-left:6px}.course-list p{font-size:10px;color:var(--muted);margin:5px 0}.course-list .course-issue{color:#9b5b2c;font-size:11px}.course-list small{font-size:9px;color:#76846c}.course-list .badge{align-self:start}.load-more{display:block;margin:15px auto;padding:10px 20px;border:1px solid var(--line);border-radius:4px;font-size:11px}.method{max-width:720px;padding:10px 0}.method h2{font-family:var(--serif);font-size:25px;font-weight:400;margin:28px 0 10px}.method p{color:#52654b;line-height:1.9;margin:0 0 24px}.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skip{position:absolute;left:15px;top:-100px;background:var(--accent);color:var(--forest);padding:10px;z-index:99}.skip:focus{top:12px} +@media(min-width:1500px){.shell{grid-template-columns:290px minmax(0,1fr)}main{padding:55px 64px 0}.topbar{padding:0 64px}.sidebar{padding-top:42px}.control{padding:30px}.summary{padding:35px 0}.empty{min-height:340px}} +@media(max-width:1150px){.shell{grid-template-columns:235px minmax(0,1fr)}.sidebar{padding:28px 13px 20px}.sidebar__description{margin-bottom:28px}.sidebar__label{font-size:9px}.program-nav button{font-size:11px;padding:10px}.primary-nav button{font-size:11px}.topbar{padding:0 27px}main{padding:35px 27px 0}.control{grid-template-columns:auto minmax(0,1fr);padding:20px}.control__actions{grid-column:2;justify-items:start}.edition{display:none}.summary{gap:10px}.summary>div{padding-left:14px}.table-head,.link-row{grid-template-columns:minmax(0,1fr) 130px}.link-row__date{grid-column:2}.table-head>span:last-child{display:none}.filters{flex-wrap:wrap}.search{flex-basis:260px}footer{flex-wrap:wrap;gap:8px}.sidebar__bottom{margin-top:25px}.sidebar{overflow-y:auto}.bottom-note{flex-wrap:wrap}} +@media(max-width:760px){.shell{display:block}.sidebar{height:auto;position:relative;padding:22px 20px 14px;overflow:visible}.brand{font-size:28px;padding:0}.brand__mark{width:32px;height:32px}.sidebar__description,.sidebar__label,.sidebar__bottom{display:none}.primary-nav{display:flex;margin-top:23px;gap:10px}.primary-nav button{font-size:11px;padding:10px;flex:1;justify-content:center}.program-nav{display:flex;overflow-x:auto;gap:7px;margin-top:15px;padding-bottom:5px}.program-nav button{white-space:nowrap;flex-shrink:0;padding:7px 11px;max-width:235px;overflow:hidden;align-items:center;background:#2d4c38;font-size:10px}.program-nav button>span{overflow:hidden;text-overflow:ellipsis}.program-nav small,.program-nav svg{display:none}.program-nav b{margin-left:4px}.topbar{height:54px;padding:0 20px;font-size:9px}.topbar__divider{margin:0 8px}.topbar button{font-size:9px;gap:5px}main{padding:28px 20px 0}.page-title{margin-bottom:23px}h1{font-size:31px;letter-spacing:-.8px}.page-title p{font-size:11px;max-width:290px;line-height:1.8}.control{gap:12px;padding:19px 16px;grid-template-columns:auto minmax(0,1fr)}.control__icon{width:38px;height:38px}.control h2{font-size:13px}.control p{font-size:10px}.control__actions{grid-column:1/-1;justify-items:stretch;margin-top:6px}.force{justify-content:center}.summary{grid-template-columns:1fr 1fr;gap:23px 10px;margin-bottom:25px;padding:25px 0}.summary>div{padding-left:17px}.summary>div:nth-child(3){padding-left:0;border:0}.summary__number{font-size:27px}.summary>div>span:last-child{font-size:10px}.results__heading{padding:18px 14px 15px;gap:6px}.results__heading h2{font-size:14px;gap:7px}.text-button{font-size:10px;gap:5px}.filters{padding:0 14px 16px;gap:8px}.search{padding:10px;font-size:10px}.status-select{width:100%}.status-select select{flex:1;max-width:none}.table-head{display:none}.link-row{padding:18px 14px;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px}.link-row__main{grid-column:1/-1}.link-row__result{grid-column:1;padding-left:19px;display:flex;gap:10px;align-items:center}.link-row__date{grid-column:2;font-size:9px}.link-row__date small{display:none}.material-name{font-size:12px}.program-name{font-size:9px}.empty{padding:29px 16px 24px;min-height:320px}.empty h3{font-size:15px}.empty p{font-size:11px}.empty__legend{gap:13px;font-size:9px}.results__note{font-size:9px;padding:12px 14px;align-items:start}.results__note svg{margin-top:2px}.bottom-note{font-size:9px}.course-list article{padding:17px 14px;flex-direction:column;gap:9px}.course-list .badge{align-self:flex-start}.scope{padding:0 14px 15px;flex-wrap:wrap;font-size:10px}.scope a{margin-left:0}footer{margin-top:35px;padding:18px 0;font-size:9px;display:grid}footer>span>span{margin-left:6px;font-size:8px}.scan-progress>div{flex-direction:column;gap:4px}.error-banner{font-size:11px}.method h2{font-size:23px}.method p{font-size:13px}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}} +.schedule{margin-top:16px;border-bottom:1px solid var(--line)}.schedule summary{display:flex;align-items:center;gap:12px;cursor:pointer;list-style:none;padding:17px 2px;color:var(--forest)}.schedule summary::-webkit-details-marker{display:none}.schedule__title{font-size:13px;font-weight:500}.schedule__title small{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px}.schedule__state{margin-left:auto;font-size:10px;color:var(--muted);white-space:nowrap}.schedule__state--on{color:#42602f}.schedule__chevron{transition:transform .15s}.schedule details[open] .schedule__chevron{transform:rotate(180deg)}.schedule__form{padding:8px 0 24px}.schedule__toggle{display:flex;align-items:center;gap:9px;font-size:12px;font-weight:500;cursor:pointer}.schedule input[type=checkbox]{accent-color:var(--forest);width:15px;height:15px;margin:0}.schedule__fields{display:flex;flex-wrap:wrap;gap:16px;margin:20px 0 14px}.schedule__fields label{display:flex;flex-direction:column;gap:7px;font-size:10px;color:var(--muted);min-width:140px;flex:1}.schedule__fields select,.schedule__fields input{border:1px solid #c9d2c3;border-radius:4px;padding:11px 10px;background:var(--white);color:var(--ink);font-size:12px;min-width:0;width:100%;height:42px}.schedule__hint{font-size:11px;line-height:1.8;color:var(--muted);max-width:80ch;margin:10px 0}.schedule__history{font-size:11px;margin:12px 0;color:#52654b}.schedule__error{font-size:11px;line-height:1.7;color:var(--red);margin:12px 0;overflow-wrap:anywhere}.schedule__actions{display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-top:19px}.schedule__actions>span{display:flex;align-items:center;gap:7px;color:#52654b;font-size:11px}@media(max-width:760px){.schedule summary{gap:9px}.schedule__title{font-size:12px}.schedule__title small{font-size:10px;max-width:230px}.schedule__state{font-size:9px}.schedule__fields{gap:12px}.schedule__fields label{flex-basis:calc(50% - 12px);min-width:120px}.schedule__actions{gap:12px}.schedule__actions .button{width:100%}} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..dcf9c61 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Сверка — доступность учебной литературы", + description: "Проверка ссылок на литературу в учебных курсах пяти программ НИУ ВШЭ.", + icons: { + icon: "/courses/favicon.svg", + shortcut: "/courses/favicon.svg", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..1a38709 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,2 @@ +import Audit from './dashboard'; +export default function Home(){return ;} diff --git a/app/schedule-panel.tsx b/app/schedule-panel.tsx new file mode 100644 index 0000000..2fd2235 --- /dev/null +++ b/app/schedule-panel.tsx @@ -0,0 +1,30 @@ +'use client'; +import {useEffect,useState} from 'react'; +import {CalendarClock,ChevronDown,LoaderCircle,Check} from 'lucide-react'; +import {type Schedule,type ScheduleSettings} from '../lib/model'; + +const DAYS=['Воскресенье','Понедельник','Вторник','Среда','Четверг','Пятница','Суббота']; +const date=(value:string|null)=>value?new Date(value).toLocaleString('ru-RU',{timeZone:'Europe/Moscow',day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'}):'—'; +export default function SchedulePanel({schedule,onSaved}:{schedule:Schedule;onSaved:(schedule:Schedule)=>void}){ + const [form,setForm]=useState(schedule),[dirty,setDirty]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[saved,setSaved]=useState(false); + useEffect(()=>{if(!dirty&&!busy)setForm(schedule);},[schedule,dirty,busy]); + function update(value:Partial){setForm(f=>({...f,...value}));setDirty(true);setSaved(false);setError('');} + async function save(event:React.FormEvent){event.preventDefault();setBusy(true);setError('');setSaved(false); + try{const response=await fetch('/courses/api/audit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'schedule',schedule:{enabled:form.enabled,frequency:form.frequency,time:form.time,weekday:form.weekday,force:form.force}})}); + const result=await response.json() as Schedule & {error?:string};if(!response.ok)throw new Error(result.error??'Не удалось сохранить расписание.');setForm(result);setDirty(false);setSaved(true);onSaved(result); + }catch(e){setError(e instanceof Error?e.message:'Не удалось сохранить расписание.');}finally{setBusy(false);} + } + return
Проверка по расписанию{schedule.enabled?`Следующий запуск: ${date(schedule.nextRunAt)} · МСК`:'Автоматическая проверка выключена'}{schedule.enabled?'Включено':'Настроить'} +
+ +
+ {form.frequency==='weekly'&&} + +
+

Запланированная проверка выполняется на сервере — вкладку можно закрыть. Новые настройки не прерывают уже начатую проверку. Если другая проверка ещё идёт, совпавший запуск пропускается.

+ {schedule.lastAttemptAt&&

{schedule.lastOutcome==='skipped'?'Последний запуск пропущен: предыдущая проверка ещё не завершена.':'Последний автоматический запуск:'} {date(schedule.lastAttemptAt)} · МСК

} + {schedule.lastError&&

Ошибка фоновой проверки: {schedule.lastError}

} + {error&&

{error}

} +
{saved?<>Расписание сохранено:dirty?'Есть несохранённые изменения':''}
+
; +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4fdc7a2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,30 @@ +name: miem-courses +services: + courses: + build: . + image: miem-courses:1.2.0 + restart: unless-stopped + init: true + user: "1000:1000" + ports: + - "127.0.0.1:${COURSES_PORT:-3011}:3000" + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + COURSES_ORIGIN: https://miem.space + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL for the dedicated courses PostgreSQL database} + networks: + - database + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 512m + cpus: 1.0 +networks: + database: + external: true + name: ${COURSES_DATABASE_NETWORK:-miem_workers_default} diff --git a/deploy/nginx-courses.conf b/deploy/nginx-courses.conf new file mode 100644 index 0000000..14e2955 --- /dev/null +++ b/deploy/nginx-courses.conf @@ -0,0 +1,17 @@ +# Include inside the existing TLS server for miem.space; preserve the /courses prefix. +location = /courses { + proxy_pass http://127.0.0.1:3011; + include /etc/nginx/proxy_params; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 35s; +} +location ^~ /courses/ { + proxy_pass http://127.0.0.1:3011; + include /etc/nginx/proxy_params; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 35s; +} diff --git a/lib/checker.ts b/lib/checker.ts new file mode 100644 index 0000000..1cb8780 --- /dev/null +++ b/lib/checker.ts @@ -0,0 +1,99 @@ +import {parseHTML} from 'linkedom'; +import {lookup} from 'node:dns/promises'; +import {request as httpRequest} from 'node:http'; +import {request as httpsRequest} from 'node:https'; +import {createUnzip,createBrotliDecompress} from 'node:zlib'; +import type {Check,Reference} from './model'; +export function safeUrl(value:string,base?:string):string|null{ +try{const u=new URL(value,base),h=u.hostname.toLowerCase(); +if(!['https:','http:'].includes(u.protocol)||u.username||u.password||(u.port&&!['80','443'].includes(u.port)))return null; +if(!h.includes('.')||h.includes(':')||/^\d+[.\d]*$/.test(h)||h.endsWith('.')||/(^|\.)(localhost|local|internal|test|invalid|example|onion|arpa)$/.test(h))return null; +u.hash='';return u.href;}catch{return null;}} +export function clean(text:string){return text.replace(/\s+/g,' ').trim();} +export function trimUrl(value:string){let v=value.replace(/[.,;:!?]+$/g,'');for(const [open,close] of [['(',')'],['[',']'],['{','}']])while(v.endsWith(close)&&v.split(close).length>v.split(open).length)v=v.slice(0,-1);return v;} +export function parseCatalog(html:string,url:string,root:string){ +const {document}=parseHTML(html),courses=new Map(),pages=new Set(); +for(const a of document.querySelectorAll('a[href]')){const href=safeUrl(a.getAttribute('href')!,url);if(!href)continue; +const u=new URL(href),r=new URL(root);if(u.origin!==r.origin)continue; +if(new RegExp('^'+r.pathname+'/[0-9]+\\.html$').test(u.pathname))courses.set(u.origin+u.pathname,{url:u.origin+u.pathname,name:clean(a.textContent??'')}); +if(new RegExp('^'+r.pathname+'/page[0-9]+\\.html$').test(u.pathname)&&(url===root||u.search===new URL(url).search))pages.add(href);} +return {courses:[...courses.values()],pages:[...pages]};} +export function parseCourse(html:string,url:string){ +const {document}=parseHTML(html),refs=new Map(); +const sections=[...document.querySelectorAll('.pud__section')].filter(s=>/список литературы|основная литература|дополнительная литература|reading list|bibliography/i.test(s.querySelector('h4,h3,h2')?.textContent??'')); +let found=sections.length>0; +if(!found){const h=[...document.querySelectorAll('h2,h3,h4')].find(h=>/^(список литературы|литература|reading list|bibliography)$/i.test(clean(h.textContent??'')));if(h){found=true;let n=h.nextElementSibling;while(n&&!/^H[1-4]$/.test(n.tagName)){sections.push(n);n=n.nextElementSibling;}}} +for(const s of sections){const items=[...s.querySelectorAll('li')];for(const item of items.length?items:[s]){ +const title=clean(item.textContent??''),urls=[...item.querySelectorAll('a[href]')].map(a=>a.getAttribute('href')!); +urls.push(...(title.match(/https?:\/\/[^\s<>"«»]+/gi)??[]).map(trimUrl)); +for(const raw of urls){const normalized=safeUrl(raw,url);if(normalized&&normalized!==url)refs.set(normalized,{url:normalized,title:title.slice(0,2000)});}}} +const content=[...document.querySelectorAll('.pud__section')].map(s=>clean(s.textContent??'')).join('\n')||clean(document.querySelector('main')?.textContent??document.querySelector('h1')?.textContent??''); +return {name:clean(document.querySelector('h1')?.textContent??''),refs:[...refs.values()],literature:found,content};} +export async function fingerprint(v:string){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(v)))].map(n=>n.toString(16).padStart(2,'0')).join('');} +export function publicIp(ip:string){ +if(ip.includes(':')){const fields=ip.toLowerCase().split(':');return /^[23][0-9a-f]{3}:/i.test(ip)&&fields[0]!=='2002'&&fields[0]!=='3fff'&&!(fields[0]==='2001'&&(parseInt(fields[1]||'0',16)<=0x1ff||parseInt(fields[1],16)===0xdb8));} +const p=ip.split('.').map(Number);if(p.length!==4||p.some(n=>!Number.isInteger(n)||n<0||n>255))return false; +const [a,b,c]=p;return !(a===0||a===10||a===127||a>=224||a===169&&b===254||a===172&&b>=16&&b<=31||a===192&&(b===168||b===0||b===88&&c===99)||a===100&&b>=64&&b<=127||a===198&&(b===18||b===19||b===51&&c===100)||a===203&&b===0&&c===113); +} +export async function resolvePublicHost(host:string){ + const addresses=await lookup(host,{all:true,verbatim:true}); + if(!addresses.length)throw new Error('DNS: адрес сервера не найден'); + if(addresses.some(a=>!publicIp(a.address)))throw new Error('DNS указывает на непубличный адрес; запрос запрещён'); + return addresses.find(a=>a.family===4)??addresses[0]; +} +export async function retrieve(raw:string,source=false){ + let url=safeUrl(raw);if(!url)throw new Error('Адрес не разрешён политикой безопасности'); + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),12000); + const aborted=new Promise((_,reject)=>controller.signal.addEventListener('abort',()=>reject(new DOMException('Истекло время ожидания','AbortError')),{once:true})); + try{ + for(let hop=0;hop<5;hop++){ + const target=new URL(url),ip=await Promise.race([resolvePublicHost(target.hostname),aborted]); + // Pin the validated address into this socket. Redirects create a new socket and repeat DNS validation. + const result=await new Promise<{code:number;location?:string;body:string;type:string}>((resolve,reject)=>{ + const request=(target.protocol==='https:'?httpsRequest:httpRequest)(target,{ + agent:false,family:ip.family,signal:controller.signal, + lookup:(_hostname,_options,callback)=>callback(null,ip.address,ip.family), + headers:{'User-Agent':'Sverka/1.1 (academic bibliography check)','Accept':'text/html,application/pdf;q=0.9,*/*;q=0.8','Accept-Encoding':'identity'}, + },response=>{ + const code=response.statusCode??0,type=String(response.headers['content-type']??''),location=response.headers.location; + if(location&&code>=300&&code<400){response.destroy();resolve({code,location,body:'',type});return;} + if(!source&&!/html|text\//i.test(type)){response.destroy();resolve({code,body:'',type});return;} + void(async()=>{ + const encoding=String(response.headers['content-encoding']??''); + const stream=/gzip|deflate/i.test(encoding)?response.pipe(createUnzip()):/br/i.test(encoding)?response.pipe(createBrotliDecompress()):response; + // pipe() does not forward source errors to a decompressor by itself. + response.on('error',error=>stream.destroy(error)); + response.on('aborted',()=>stream.destroy(new Error('Соединение прервано'))); + request.once('error',error=>{response.destroy();stream.destroy(error);}); + const chunks:Buffer[]=[];let size=0; + try{for await(const chunk of stream){const bytes=Buffer.from(chunk);size+=bytes.length;if(size>(source?3000000:1500000))throw new Error('Превышен допустимый размер загрузки');chunks.push(bytes);} + const charset=/charset=([\w-]+)/i.exec(type)?.[1]??'utf-8';let body:string;try{body=new TextDecoder(charset).decode(Buffer.concat(chunks));}catch{body=Buffer.concat(chunks).toString('utf8');}resolve({code,body,type}); + }catch(error){response.destroy();stream.destroy();reject(error);} + })(); + });request.on('error',reject);request.end(); + }); + if(result.location&&result.code>=300&&result.code<400){const next=safeUrl(result.location,url);if(!next)throw new Error('Небезопасное перенаправление');url=next;continue;} + return {...result,url}; + } + throw new Error('Слишком много перенаправлений'); + }finally{clearTimeout(timer);} +} +export function classify(url:string,r:{code:number;url:string;body:string;type:string}):Check{ +const out:Check={url,finalUrl:r.url,code:r.code,checkedAt:new Date().toISOString(),status:'ok',reason:'Сервер вернул материал. Доступ к полному тексту может потребовать ручной проверки.'}; +if(r.code===404||r.code===410)return {...out,status:'broken',reason:`HTTP ${r.code}: ${r.code===404?'страница не найдена':'материал удалён'}.`}; +if(r.code===401||/(?:\/|\.)(?:login|signin|sign-in|sso|auth)(?:[/.?]|$)/i.test(r.url))return {...out,status:'auth',reason:'Требуется авторизация или выполнено перенаправление на вход.'}; +if(r.code===403||r.code===429)return {...out,status:'blocked',reason:`HTTP ${r.code}: сервер ограничил автоматический доступ. Проверьте вручную.`}; +if(r.code>=400)return {...out,status:'network',reason:`HTTP ${r.code}: сервер не смог предоставить материал.`}; +if(r.code<200||r.code>=300)return {...out,status:'unknown',reason:`Необычный ответ HTTP ${r.code}; нужна ручная проверка.`}; +if(r.code===204||r.code===205)return {...out,status:'unknown',reason:`HTTP ${r.code}: сервер вернул ответ без содержимого. Доступ к материалу не подтверждён.`}; +if(!/html|text\//i.test(r.type))return out; +if(!r.body.trim())return {...out,status:'unknown',reason:'Сервер вернул пустую страницу. Доступ к материалу не подтверждён.'}; +const {document}=parseHTML(r.body);const scripted=!!document.querySelector('script');document.querySelectorAll('script,style,nav,footer,header').forEach(n=>n.remove()); +const title=clean(document.querySelector('title,h1')?.textContent??''),text=clean(document.querySelector('body')?.textContent??document.documentElement?.textContent??r.body); +if(/captcha|verify you are human|checking your browser|just a moment|докажите, что вы|проверка браузера|доступ ограничен/i.test(title+' '+text.slice(0,2500)))return {...out,status:'blocked',reason:'CAPTCHA или антибот-защита: автоматически подтвердить доступность нельзя.'}; +if(/^(404|страница не найдена|page not found|not found)/i.test(title))return {...out,status:'broken',reason:'Сайт показывает «не найдено» при успешном HTTP-ответе.'}; +if(document.querySelector('input[type="password"]')&&text.length<10000)return {...out,status:'auth',reason:'Обнаружена форма входа с паролем; доступ к материалу не подтверждён.'}; +if(/(для (?:чтения|доступа|просмотра).{0,80}(?:авториз|войд|подписк)|(?:авторизуйтесь|войдите).{0,80}(?:чита|доступ|просмотр)|sign in to (?:read|access|view)|purchase (?:this|access)|institutional access|доступ (?:к полному тексту|по подписке)|читать по подписке)/i.test(text))return {...out,status:'restricted',reason:'Для полного текста может потребоваться вход, подписка или доступ организации. Проверьте вручную.'}; +if(/html/i.test(r.type)&&!text)return {...out,status:'unknown',reason:'Получена пустая HTML-страница. Доступ к материалу не подтверждён.'}; +if(/html/i.test(r.type)&&((scripted&&text.length<200)||/^(загрузка|loading)[.\s…]*$/i.test(text)))return {...out,status:'unknown',reason:'Получена оболочка страницы с загрузкой через JavaScript. Доступ к материалу не подтверждён; откройте ссылку вручную.'};return out;} +export async function checkLink(url:string):Promise{try{return classify(url,await retrieve(url));}catch(e){const error=e as Error;return {url,status:error.name==='AbortError'?'timeout':'network',reason:error.name==='AbortError'?'Сервер не ответил за 12 секунд.':`Не удалось загрузить: ${error.message.slice(0,180)}`,checkedAt:new Date().toISOString()};}} diff --git a/lib/model.ts b/lib/model.ts new file mode 100644 index 0000000..3b0c588 --- /dev/null +++ b/lib/model.ts @@ -0,0 +1,17 @@ +export const VERSION='1.2.0'; +export const PROGRAMS=[ +{id:'it',name:'Цифровые технологии и телекоммуникации',level:'Бакалавриат',url:'https://www.hse.ru/ba/it/courses'}, +{id:'is',name:'Информационная безопасность',level:'Бакалавриат',url:'https://www.hse.ru/ba/is/courses'}, +{id:'cosec',name:'Компьютерная безопасность',level:'Специалитет',url:'https://www.hse.ru/ba/cosec/courses'}, +{id:'internet',name:'Интернет вещей и киберфизические системы',level:'Магистратура',url:'https://www.hse.ru/ma/internet/courses'}, +{id:'cyber',name:'Информационная безопасность и технологии искусственного интеллекта',level:'Магистратура',url:'https://www.hse.ru/ma/cyber/courses'}]; +export type Status='ok'|'broken'|'auth'|'blocked'|'timeout'|'network'|'restricted'|'unknown'; +export const LABELS:Record={ok:'Доступна',broken:'Не найдена',auth:'Требуется вход',blocked:'Доступ запрещён',timeout:'Нет ответа',network:'Ошибка соединения',restricted:'Ограничен доступ',unknown:'Нужна проверка'}; +export type Check={url:string;finalUrl?:string;status:Status;reason:string;code?:number;checkedAt:string}; +export type Reference={url:string;title:string}; +export type Course={url:string;name:string;program:string;hash?:string;refs:Reference[];issue?:string;checkedAt:string;seen:string;reused:boolean;literature:boolean}; +export type Run={id:string;source?:'manual'|'schedule';status:'running'|'complete';startedAt:string;finishedAt?:string;force:boolean;phase:'catalogs'|'courses'|'links'|'done';catalogs:{url:string;program:string}[];visited:string[];courses:{url:string;name:string;program:string}[];links:string[];seen:string[];errors:{url:string;reason:string}[];processed:number;reused:number;checked:number;totalCourses:number;totalLinks:number;message:string}; +export type ScheduleSettings={enabled:boolean;frequency:'daily'|'weekly';time:string;weekday:number;force:boolean}; +export type Schedule=ScheduleSettings & {nextRunAt:string|null;updatedAt:string|null;lastRunAt:string|null;lastAttemptAt:string|null;lastOutcome:'started'|'skipped'|null;lastError:string|null}; +export const DEFAULT_SCHEDULE:Schedule={enabled:false,frequency:'daily',time:'09:00',weekday:1,force:false,nextRunAt:null,updatedAt:null,lastRunAt:null,lastAttemptAt:null,lastOutcome:null,lastError:null}; +export type Dashboard={run:Run|null;courses:Course[];checks:Record;programs:typeof PROGRAMS;schedule:Schedule}; diff --git a/lib/scanner.ts b/lib/scanner.ts new file mode 100644 index 0000000..d17a38b --- /dev/null +++ b/lib/scanner.ts @@ -0,0 +1,46 @@ +import {PROGRAMS,DEFAULT_SCHEDULE,type Schedule,type Run,type Course,type Check} from './model.ts'; +import {checkLink,retrieve,parseCatalog,parseCourse,fingerprint} from './checker.ts'; +import {database,transaction,type Connection} from './store.ts'; +async function read(key:string):Promise{const {rows}=await (await database()).query<{value:string}>('SELECT value FROM records WHERE key=$1',[key]);return rows[0]?JSON.parse(rows[0].value):null;} +export async function getRun(connection?:Connection):Promise{const {rows}=await (connection??await database()).query<{data:string}>('SELECT data FROM scan WHERE id=1');return rows[0]?JSON.parse(rows[0].data):null;} +export async function dashboard(){const run=await getRun(),{rows}=await (await database()).query<{key:string;value:string}>('SELECT key,value FROM records'),courses:Course[]=[],checks:Record={}; +let schedule:Schedule={...DEFAULT_SCHEDULE};for(const row of rows){if(row.key.startsWith('course:'))courses.push(JSON.parse(row.value));else if(row.key.startsWith('link:'))checks[row.key.slice(5)]=JSON.parse(row.value);else if(row.key==='schedule')schedule=JSON.parse(row.value);}return {run,courses,checks,programs:PROGRAMS,schedule};} +export async function start(force:boolean,source:'manual'|'schedule'='manual',connection?:Connection){const run:Run={id:crypto.randomUUID(),source,status:'running',phase:'catalogs',startedAt:new Date().toISOString(),force,catalogs:PROGRAMS.map(p=>({url:p.url,program:p.id})),visited:[],courses:[],links:[],seen:[],errors:[],processed:0,reused:0,checked:0,totalCourses:0,totalLinks:0,message:'Читаем каталоги пяти программ'}; +const db=connection??await database();await db.query("INSERT INTO scan(id,data,lease,token) VALUES(1,$1,0,'') ON CONFLICT(id) DO UPDATE SET data=excluded.data,lease=0,token='' WHERE scan.data::jsonb->>'status'!='running'",[JSON.stringify(run)]);return getRun(db);} +export async function step(){const token=crypto.randomUUID(),now=Date.now(),db=await database(); +const {rows}=await db.query<{data:string}>('UPDATE scan SET lease=$1,token=$2 WHERE id=1 AND lease<$3 RETURNING data',[now+90000,token,now]);if(!rows[0])return getRun();const run:Run=JSON.parse(rows[0].data);const pending:{key:string;value:unknown}[]=[];let succeeded=false; +try{if(run.status!=='running')return run; +if(run.phase==='catalogs'){ +const task=run.catalogs.shift();if(task&&!run.visited.includes(task.url)){run.visited.push(task.url);run.message=`Читаем каталог: ${PROGRAMS.find(p=>p.id===task.program)?.name}`; +try{const page=await retrieve(task.url,true);if(page.code!==200)throw new Error(`Каталог вернул HTTP ${page.code}`); +const root=PROGRAMS.find(p=>p.id===task.program)!.url,parsed=parseCatalog(page.body,task.url,root);if(!parsed.courses.length)throw new Error('Курсы не найдены: каталог недоступен или его структура изменилась'); +for(const c of parsed.courses)if(!run.seen.includes(c.url)){run.seen.push(c.url);run.courses.push({...c,program:task.program});} +for(const url of parsed.pages)if(!run.visited.includes(url)&&!run.catalogs.some(p=>p.url===url))run.catalogs.push({url,program:task.program});run.totalCourses=run.seen.length; +}catch(e){run.errors.push({url:task.url,reason:(e as Error).message});}} +if(!run.catalogs.length)run.phase='courses'; +}else if(run.phase==='courses'){ +const task=run.courses.shift();if(task){run.message=`Читаем курс: ${task.name}`;const old=await read('course:'+task.url); +let course:Course={...task,refs:old?.refs??[],checkedAt:old?.checkedAt??'',seen:run.id,reused:false,literature:false}; +try{const page=await retrieve(task.url,true);if(page.code!==200)throw new Error(`Страница курса вернула HTTP ${page.code}`); +const parsed=parseCourse(page.body,task.url);if(!parsed.name)throw new Error('Не удалось прочитать программу курса');const hash=await fingerprint(parsed.name+parsed.content+JSON.stringify(parsed.refs)); +if(!run.force&&old?.hash===hash&&!old.issue){course={...old,seen:run.id,reused:true};run.reused++;} +else{course={...course,name:parsed.name,hash,refs:parsed.refs,literature:parsed.literature,checkedAt:new Date().toISOString()};if(!parsed.literature)course.issue='Раздел литературы не найден. Проверьте программу курса вручную.'; +for(const ref of course.refs)if(!run.links.includes(ref.url))run.links.push(ref.url);} +}catch(e){course.issue=(e as Error).message;} +pending.push({key:'course:'+task.url,value:course});run.processed++;} +if(!run.courses.length){run.phase='links';run.totalLinks=run.links.length;} +}else if(run.phase==='links'){ +const urls=run.links.splice(0,3);run.message='Проверяем доступность материалов';const results=await Promise.all(urls.map(checkLink));for(const result of results){pending.push({key:'link:'+result.url,value:result});run.checked++;} +if(!run.links.length){run.status='complete';run.phase='done';run.finishedAt=new Date().toISOString();run.message=run.errors.length?'Проверка завершена с ошибками чтения каталогов':'Проверка завершена';} +} +succeeded=true; +}finally{ +if(succeeded){ +try{await transaction(async client=>{ +const {rows}=await client.query<{token:string}>('SELECT token FROM scan WHERE id=1 FOR UPDATE'); +if(rows[0]?.token!==token)throw new Error('Порция проверки уже выполняется другим процессом'); +for(const record of pending)await client.query('INSERT INTO records(key,value) VALUES($1,$2) ON CONFLICT(key) DO UPDATE SET value=excluded.value',[record.key,JSON.stringify(record.value)]); +await client.query("UPDATE scan SET data=$1,lease=0,token='' WHERE id=1 AND token=$2",[JSON.stringify(run),token]); +});}catch(error){await db.query("UPDATE scan SET lease=0,token='' WHERE id=1 AND token=$1",[token]);throw error;} +}else await db.query("UPDATE scan SET lease=0,token='' WHERE id=1 AND token=$1",[token]); +}return run;} diff --git a/lib/schedule-worker.ts b/lib/schedule-worker.ts new file mode 100644 index 0000000..3d24c02 --- /dev/null +++ b/lib/schedule-worker.ts @@ -0,0 +1,10 @@ +import {setTimeout as sleep} from 'node:timers/promises'; +import {schedulerStep,recordSchedulerError} from './scheduler.ts'; + +// The worker shares SQLite leases with the API; restarting the container resumes scheduled runs. +for(;;){ + let busy=false; + try{busy=await schedulerStep();} + catch(error){console.error('Scheduled audit failed; retrying in five seconds');try{await recordSchedulerError(error);}catch(storageError){console.error('Cannot save scheduler error');}} + await sleep(busy?350:5000); +} diff --git a/lib/scheduler.ts b/lib/scheduler.ts new file mode 100644 index 0000000..f9d2c16 --- /dev/null +++ b/lib/scheduler.ts @@ -0,0 +1,66 @@ +import {DEFAULT_SCHEDULE,type Schedule,type ScheduleSettings} from './model.ts'; +import {database,transaction,type Connection} from './store.ts'; +import {getRun,start,step} from './scanner.ts'; + +export function validateSchedule(value:unknown):ScheduleSettings{ + if(!value||typeof value!=='object')throw new RangeError('Укажите настройки расписания.'); + const s=value as Record; + if(typeof s.enabled!=='boolean'||typeof s.force!=='boolean')throw new RangeError('Укажите состояние расписания и режим кэша.'); + if(s.frequency!=='daily'&&s.frequency!=='weekly')throw new RangeError('Выберите ежедневную или еженедельную проверку.'); + if(typeof s.time!=='string'||!/^([01]\d|2[0-3]):[0-5]\d$/.test(s.time))throw new RangeError('Укажите время в формате ЧЧ:ММ.'); + if(!Number.isInteger(s.weekday)||Number(s.weekday)<0||Number(s.weekday)>6)throw new RangeError('Выберите день недели.'); + return {enabled:s.enabled,frequency:s.frequency,time:s.time,weekday:s.weekday as number,force:s.force}; +} +const clock=new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Moscow',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}); +function moscowWallTime(instant:number){const parts=Object.fromEntries(clock.formatToParts(instant).map(p=>[p.type,p.value]));return Date.UTC(+parts.year,+parts.month-1,+parts.day,+parts.hour,+parts.minute,+parts.second);} +export function nextOccurrence(settings:ScheduleSettings,now=Date.now()):string{ + const local=new Date(moscowWallTime(now)),[hour,minute]=settings.time.split(':').map(Number); + for(let days=0;days<=7;days++){ + const wall=Date.UTC(local.getUTCFullYear(),local.getUTCMonth(),local.getUTCDate()+days,hour,minute); + if(settings.frequency==='weekly'&&new Date(wall).getUTCDay()!==settings.weekday)continue; + let instant=wall;for(let i=0;i<3;i++)instant+=wall-moscowWallTime(instant); + if(instant>now)return new Date(instant).toISOString(); + } + throw new Error('Не удалось рассчитать следующий запуск.'); +} +export async function getSchedule(connection?:Connection):Promise{ + const {rows}=await (connection??await database()).query<{value:string}>("SELECT value FROM records WHERE key='schedule'"); + return rows[0]?JSON.parse(rows[0].value):{...DEFAULT_SCHEDULE}; +} +async function persist(schedule:Schedule,client:Connection){await client.query("INSERT INTO records(key,value) VALUES('schedule',$1) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[JSON.stringify(schedule)]);} +export async function saveSchedule(value:unknown,now=Date.now()):Promise{ + const settings=validateSchedule(value); + return transaction(async client=>{ + await client.query('SELECT pg_advisory_xact_lock(72847322)'); + const schedule:Schedule={...await getSchedule(client),...settings,updatedAt:new Date(now).toISOString(),nextRunAt:settings.enabled?nextOccurrence(settings,now):null}; + await persist(schedule,client);return schedule; + }); +} +export async function claimScheduledRun(now=Date.now()){ + await transaction(async client=>{ + await client.query('SELECT pg_advisory_xact_lock(72847322)'); + const schedule=await getSchedule(client); + if(schedule.enabled&&schedule.nextRunAt&&Date.parse(schedule.nextRunAt)<=now){ + const run=await getRun(client);schedule.lastAttemptAt=new Date(now).toISOString();schedule.nextRunAt=nextOccurrence(schedule,now); + if(run?.status==='running')schedule.lastOutcome='skipped'; + else{ + const started=await start(schedule.force,'schedule',client); + if(started?.source==='schedule'){schedule.lastRunAt=new Date(now).toISOString();schedule.lastOutcome='started';schedule.lastError=null;} + else schedule.lastOutcome='skipped'; + } + await persist(schedule,client); + } + }); +} +export async function schedulerStep(now=Date.now()){ + await claimScheduledRun(now); + const run=await getRun(); + if(run?.source==='schedule'&&run.status==='running'){await step();return true;} + return false; +} +export async function recordSchedulerError(error:unknown){ + await transaction(async client=>{ + await client.query('SELECT pg_advisory_xact_lock(72847322)'); + const schedule=await getSchedule(client);schedule.lastError='Не удалось обработать очередь. Сервер повторит попытку через пять секунд.';await persist(schedule,client); + }); +} diff --git a/lib/store.ts b/lib/store.ts new file mode 100644 index 0000000..92c382d --- /dev/null +++ b/lib/store.ts @@ -0,0 +1,26 @@ +import {Pool,type PoolClient} from 'pg'; +export type Connection=Pool|PoolClient; +const runtime=globalThis as typeof globalThis & {coursesPool?:Pool;coursesReady?:Promise}; +async function inTransaction(pool:Pool,work:(client:PoolClient)=>Promise):Promise{ + const client=await pool.connect(); + try{await client.query('BEGIN');const result=await work(client);await client.query('COMMIT');return result;} + catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;} + finally{client.release();} +} +export async function database():Promise{ + if(!runtime.coursesPool){ + if(!process.env.DATABASE_URL)throw new Error('DATABASE_URL is required'); + runtime.coursesPool=new Pool({connectionString:process.env.DATABASE_URL,max:4,connectionTimeoutMillis:5000,idleTimeoutMillis:30000,statement_timeout:15000}); + runtime.coursesPool.on('error',()=>console.error('PostgreSQL connection unavailable')); + } + const pool=runtime.coursesPool; + runtime.coursesReady??=inTransaction(pool,async client=>{ + await client.query('SELECT pg_advisory_xact_lock(72847321)'); + await client.query('CREATE TABLE IF NOT EXISTS records(key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL)'); + await client.query("CREATE TABLE IF NOT EXISTS scan(id INTEGER PRIMARY KEY,data TEXT NOT NULL,lease BIGINT NOT NULL DEFAULT 0,token TEXT NOT NULL DEFAULT '')"); + }); + try{await runtime.coursesReady;}catch(error){runtime.coursesReady=undefined;throw error;} + return pool; +} +export async function transaction(work:(client:PoolClient)=>Promise):Promise{return inTransaction(await database(),work);} +export async function closeDatabase(){const pool=runtime.coursesPool;runtime.coursesPool=undefined;runtime.coursesReady=undefined;if(pool)await pool.end();} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..d4c02be --- /dev/null +++ b/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: 'standalone', + basePath: '/courses', + serverExternalPackages: ['linkedom','pg'], +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a3cc2f --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "sverka", + "version": "1.2.0", + "private": true, + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "dev": "node server/runtime.mjs --dev", + "build": "next build --webpack", + "start": "node server/runtime.mjs", + "test": "node --experimental-strip-types --test tests/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "16.3.4", + "react": "19.2.6", + "react-dom": "19.2.6", + "linkedom": "0.18.14", + "lucide-react": "1.63.0", + "pg": "8.16.3" + }, + "devDependencies": { + "@types/node": "22.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", + "@types/pg": "8.15.5" + }, + "type": "module", + "packageManager": "pnpm@11.25.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..996d89f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1291 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + linkedom: + specifier: 0.18.14 + version: 0.18.14 + lucide-react: + specifier: 1.63.0 + version: 1.63.0(@types/react@19.2.14)(react@19.2.6) + next: + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@types/node@22.19.19)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + pg: + specifier: 8.16.3 + version: 8.16.3 + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + devDependencies: + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@types/pg': + specifier: 8.15.5 + version: 8.15.5 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: 5.9.3 + version: 5.9.3 + +packages: + + '@acemir/cssom@0.9.32': + resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.11': + resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} + + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/pg@8.15.5': + resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + baseline-browser-mapping@2.11.34: + resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==} + engines: {node: '>=6.0.0'} + hasBin: true + + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001819: + resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + electron-to-chromium@1.5.467: + resolution: {integrity: sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + linkedom@0.18.14: + resolution: {integrity: sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.63.0: + resolution: {integrity: sha512-GkBfQG+FUJ41x6y3/lNXiOziVU0Rcqqq0TCzhHS8avsFXHZmOdmfBqsVTvHSaVmbI+wbdGkxHRmhYWhD3N4/yA==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-releases@2.0.60: + resolution: {integrity: sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==} + engines: {node: '>=18'} + + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@acemir/cssom@0.9.32': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + optional: true + + '@babel/compat-data@7.29.7': + optional: true + + '@babel/core@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helpers': 7.29.10 + '@babel/parser': 7.29.11 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.10(supports-color@10.2.2) + '@babel/types': 7.29.11 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.11 + '@babel/types': 7.29.11 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + optional: true + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.5 + lru-cache: 5.1.1 + semver: 6.3.1 + optional: true + + '@babel/helper-globals@7.29.7': + optional: true + + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.10(supports-color@10.2.2) + '@babel/types': 7.29.11 + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.10(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/helper-string-parser@7.29.11': + optional: true + + '@babel/helper-validator-identifier@7.29.7': + optional: true + + '@babel/helper-validator-option@7.29.7': + optional: true + + '@babel/helpers@7.29.10': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 + optional: true + + '@babel/parser@7.29.11': + dependencies: + '@babel/types': 7.29.11 + optional: true + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.11 + '@babel/types': 7.29.11 + optional: true + + '@babel/traverse@7.29.10(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.11 + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/types@7.29.11': + dependencies: + '@babel/helper-string-parser': 7.29.11 + '@babel/helper-validator-identifier': 7.29.7 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + + '@jridgewell/resolve-uri@3.1.2': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': + optional: true + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + optional: true + + '@next/env@16.3.4': {} + + '@next/swc-darwin-arm64@16.3.4': + optional: true + + '@next/swc-darwin-x64@16.3.4': + optional: true + + '@next/swc-linux-arm64-gnu@16.3.4': + optional: true + + '@next/swc-linux-arm64-musl@16.3.4': + optional: true + + '@next/swc-linux-x64-gnu@16.3.4': + optional: true + + '@next/swc-linux-x64-musl@16.3.4': + optional: true + + '@next/swc-win32-arm64-msvc@16.3.4': + optional: true + + '@next/swc-win32-x64-msvc@16.3.4': + optional: true + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.15.5': + dependencies: + '@types/node': 22.19.19 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + baseline-browser-mapping@2.11.34: {} + + boolbase@2.0.0: {} + + browserslist@4.29.5: + dependencies: + baseline-browser-mapping: 2.11.34 + caniuse-lite: 1.0.30001819 + electron-to-chromium: 1.5.467 + node-releases: 2.0.60 + update-browserslist-db: 1.3.4(browserslist@4.29.5) + optional: true + + caniuse-lite@1.0.30001819: {} + + client-only@0.0.1: {} + + convert-source-map@2.0.0: + optional: true + + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-what@8.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + optional: true + + detect-libc@2.1.2: + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + electron-to-chromium@1.5.467: + optional: true + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.1.0: {} + + escalade@3.2.0: + optional: true + + gensync@1.0.0-beta.2: + optional: true + + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + js-tokens@4.0.0: + optional: true + + jsesc@3.1.0: + optional: true + + json5@2.2.3: + optional: true + + linkedom@0.18.14: + dependencies: + '@acemir/cssom': 0.9.32 + css-select: 7.0.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + optional: true + + lucide-react@1.63.0(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + ms@2.1.3: + optional: true + + nanoid@3.3.20: {} + + next@16.3.4(@babel/core@7.29.7(supports-color@10.2.2))(@types/node@22.19.19)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.34 + caniuse-lite: 1.0.30001819 + postcss: 8.5.23 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.6) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.5(@types/node@22.19.19) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + node-releases@2.0.60: + optional: true + + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.16.3) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + scheduler@0.27.0: {} + + semver@6.3.1: + optional: true + + semver@7.8.5: + optional: true + + sharp@0.35.5(@types/node@22.19.19): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.19.19 + optional: true + + source-map-js@1.2.2: {} + + split2@4.2.0: {} + + styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.6): + dependencies: + client-only: 0.0.1 + react: 19.2.6 + optionalDependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + + supports-color@10.2.2: + optional: true + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + uhyphen@0.2.0: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.3.4(browserslist@4.29.5): + dependencies: + browserslist: 4.29.5 + escalade: 3.2.0 + picocolors: 1.1.1 + optional: true + + xtend@4.0.2: {} + + yallist@3.1.1: + optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..9be25d9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + sharp: true + unrs-resolver: true + workerd: true diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..f61e600 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1 @@ +export default {plugins:{}}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..46fc005 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/fonts/GolosText-OFL.txt b/public/fonts/GolosText-OFL.txt new file mode 100644 index 0000000..4db2dc9 --- /dev/null +++ b/public/fonts/GolosText-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Lora-OFL.txt b/public/fonts/Lora-OFL.txt new file mode 100644 index 0000000..d0ce7fe --- /dev/null +++ b/public/fonts/Lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/font-0.woff2 b/public/fonts/font-0.woff2 new file mode 100644 index 0000000..f7e94e0 Binary files /dev/null and b/public/fonts/font-0.woff2 differ diff --git a/public/fonts/font-1.woff2 b/public/fonts/font-1.woff2 new file mode 100644 index 0000000..02c1aa4 Binary files /dev/null and b/public/fonts/font-1.woff2 differ diff --git a/public/fonts/font-2.woff2 b/public/fonts/font-2.woff2 new file mode 100644 index 0000000..2530a42 Binary files /dev/null and b/public/fonts/font-2.woff2 differ diff --git a/public/fonts/font-3.woff2 b/public/fonts/font-3.woff2 new file mode 100644 index 0000000..2f4b14c Binary files /dev/null and b/public/fonts/font-3.woff2 differ diff --git a/public/fonts/font-4.woff2 b/public/fonts/font-4.woff2 new file mode 100644 index 0000000..bd2b7cb Binary files /dev/null and b/public/fonts/font-4.woff2 differ diff --git a/public/fonts/font-5.woff2 b/public/fonts/font-5.woff2 new file mode 100644 index 0000000..913c1dd Binary files /dev/null and b/public/fonts/font-5.woff2 differ diff --git a/public/fonts/fonts.css b/public/fonts/fonts.css new file mode 100644 index 0000000..fcdaec0 --- /dev/null +++ b/public/fonts/fonts.css @@ -0,0 +1,42 @@ +@font-face { + font-family: 'Golos Text'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/courses/fonts/font-0.woff2) format('truetype'); +} +@font-face { + font-family: 'Golos Text'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/courses/fonts/font-1.woff2) format('truetype'); +} +@font-face { + font-family: 'Golos Text'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/courses/fonts/font-2.woff2) format('truetype'); +} +@font-face { + font-family: 'Golos Text'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(/courses/fonts/font-3.woff2) format('truetype'); +} +@font-face { + font-family: 'Lora'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/courses/fonts/font-4.woff2) format('truetype'); +} +@font-face { + font-family: 'Lora'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/courses/fonts/font-5.woff2) format('truetype'); +} diff --git a/server/runtime.mjs b/server/runtime.mjs new file mode 100644 index 0000000..aaa3104 --- /dev/null +++ b/server/runtime.mjs @@ -0,0 +1,14 @@ +import {spawn} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {join,resolve} from 'node:path'; +import {cpSync} from 'node:fs'; + +const root=resolve(fileURLToPath(new URL('..',import.meta.url))); +const dev=process.argv.includes('--dev'),standalone=process.argv.includes('--standalone'); +if(!dev&&!standalone){cpSync(join(root,'public'),join(root,'.next/standalone/public'),{recursive:true});cpSync(join(root,'.next/static'),join(root,'.next/standalone/.next/static'),{recursive:true});} +const webArgs=standalone?['server.js']:dev?[join(root,'node_modules/next/dist/bin/next'),'dev','--webpack','-H','127.0.0.1','-p',process.env.PORT??'5173']:[join(root,'.next/standalone/server.js')]; +const children=[spawn(process.execPath,webArgs,{cwd:root,stdio:'inherit'}),spawn(process.execPath,['--experimental-strip-types',join(root,'lib/schedule-worker.ts')],{cwd:root,stdio:'inherit'})]; +let stopping=false; +function stop(code){if(stopping)return;stopping=true;process.exitCode=code;for(const child of children)if(child.exitCode===null)child.kill('SIGTERM');const timer=setTimeout(()=>{for(const child of children)if(child.exitCode===null)child.kill('SIGKILL');},5000);timer.unref();} +for(const child of children){child.on('error',error=>{console.error(error);stop(1);});child.on('exit',(code,signal)=>{if(!stopping)stop(code||1);});} +process.on('SIGINT',()=>stop(0));process.on('SIGTERM',()=>stop(0)); diff --git a/tests/checker.test.ts b/tests/checker.test.ts new file mode 100644 index 0000000..33f5814 --- /dev/null +++ b/tests/checker.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; +import {parseCatalog,parseCourse,classify,safeUrl,fingerprint} from '../lib/checker.ts'; +const url='https://www.hse.ru/ba/is/courses/1.html'; +const result=(body='',code=200,finalUrl='https://public.org/book')=>({body,code,url:finalUrl,type:'text/html'}); +const html='Book

Algorithms

Список литературы

  • Книга https://books.org/a. читать
  • Другая https://books.org/b (дата).
'; +test('extracts linked and plain-text bibliography, normalizes and excludes footer',()=>{const p=parseCourse(html,url);assert.equal(p.name,'Algorithms');assert.deepEqual(p.refs.map(r=>r.url),['https://books.org/a','https://books.org/b']);assert.equal(p.literature,true);}); +test('missing bibliography is explicit',()=>assert.equal(parseCourse('

Course

',url).literature,false)); +test('pagination follows current program and excludes archived years',()=>{const root='https://www.hse.ru/ba/is/courses',p=parseCatalog('A2archiveother',root,root);assert.equal(p.courses.length,1);assert.deepEqual(p.pages,[root+'/page2.html?year=2026']);}); +test('classifies errors, login, protection and subscription separately',()=>{assert.equal(classify(url,result('',404)).status,'broken');assert.equal(classify(url,result('',403)).status,'blocked');assert.equal(classify(url,result('',401)).status,'auth');assert.equal(classify(url,result('',200,'https://public.org/login')).status,'auth');assert.equal(classify(url,result('Just a momentChecking your browser')).status,'blocked');assert.equal(classify(url,result('Sign in to read this book')).status,'restricted');assert.equal(classify(url,result('')).status,'auth');assert.equal(classify(url,result('Open book')).status,'ok');}); +test('rejects dangerous and non-http URLs',()=>{for(const v of ['http://127.0.0.1','http://[::1]','http://localhost/a','https://foo.internal','file:///a','https://a:b@public.org','https://public.org:8080'])assert.equal(safeUrl(v),null);assert.equal(safeUrl('https://public.org/book#x'),'https://public.org/book');}); +test('hash detects content changes and remains stable',async()=>{assert.equal(await fingerprint(html),await fingerprint(html));assert.notEqual(await fingerprint(html),await fingerprint(html+'x'));}); +import {publicIp,trimUrl} from '../lib/checker.ts'; +test('preserves balanced parentheses in URLs',()=>{assert.equal(trimUrl('https://public.org/Foo_(bar).'),'https://public.org/Foo_(bar)');assert.equal(trimUrl('https://public.org/book).'),'https://public.org/book');}); +test('rejects private, reserved and loopback DNS answers',()=>{for(const ip of ['127.0.0.1','10.1.2.3','169.254.169.254','100.64.0.1','172.16.2.2','192.168.1.1','192.0.2.1','198.18.1.1','203.0.113.1','::1','fc00::1','fe80::1','2001:db8::1'])assert.equal(publicIp(ip),false,ip);assert.equal(publicIp('1.1.1.1'),true);assert.equal(publicIp('2606:4700:4700::1111'),true);}); +test('does not mark a JavaScript loading shell as accessible',()=>{assert.equal(classify(url,result('
Загрузка...
')).status,'unknown');}); +test('empty HTML and No Content responses do not prove material access',()=>{for(const code of [200,204,205])assert.equal(classify(url,result('',code)).status,'unknown');}); +test('non-successful binary responses do not count as available material',()=>{for(const code of [302,304])assert.equal(classify(url,{...result('',code),type:'application/pdf'}).status,'unknown');assert.equal(classify(url,{...result(),type:'application/pdf'}).status,'ok');}); diff --git a/tests/origin.test.ts b/tests/origin.test.ts new file mode 100644 index 0000000..285954c --- /dev/null +++ b/tests/origin.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {POST} from '../app/api/audit/route.ts'; +const request=(origin:string,host:string)=>new Request('http://localhost:5173/courses/api/audit',{method:'POST',headers:{Origin:origin,Host:host,'Content-Type':'application/json'},body:JSON.stringify({action:'invalid'})}); +test('development accepts the actual browser Host, not Next internal localhost',async()=>{ +delete process.env.COURSES_ORIGIN; +assert.equal((await POST(request('http://127.0.0.1:5173','127.0.0.1:5173'))).status,400); +assert.equal((await POST(request('https://other.example','127.0.0.1:5173'))).status,403); +}); +test('canonical production origin works behind a proxy and rejects another site',async()=>{ +process.env.COURSES_ORIGIN='https://miem.space'; +assert.equal((await POST(request('https://miem.space','internal:3000'))).status,400); +assert.equal((await POST(request('https://other.example','internal:3000'))).status,403); +delete process.env.COURSES_ORIGIN; +}); diff --git a/tests/scanner.test.ts b/tests/scanner.test.ts new file mode 100644 index 0000000..8f9519e --- /dev/null +++ b/tests/scanner.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {database} from '../lib/store.ts'; +import {dashboard,getRun,start,step} from '../lib/scanner.ts'; +import type {Run} from '../lib/model.ts'; +import {useTestDatabase} from './test-database.ts'; +useTestDatabase('scanner'); +test('only one shared manual run can be started',async()=>{ + const [first,second]=await Promise.all([start(false),start(false)]); + assert.equal(first?.id,second?.id);assert.equal(second?.force,false); +}); +test('a failed record transaction preserves its queue and can resume',async()=>{ + const run=(await getRun())!;run.phase='links';run.links=['http://127.0.0.1/'];run.totalLinks=1;run.checked=0; + const db=await database();await db.query("UPDATE scan SET data=$1,lease=0,token='' WHERE id=1",[JSON.stringify(run)]); + await db.query("CREATE FUNCTION reject_record() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'simulated disk failure'; END $$"); + await db.query('CREATE TRIGGER fail_record BEFORE INSERT ON records FOR EACH ROW EXECUTE FUNCTION reject_record()'); + try{await assert.rejects(step(),/simulated disk failure/);const afterFailure=(await getRun())!;assert.deepEqual(afterFailure.links,run.links);assert.equal(afterFailure.checked,0);assert.equal((await dashboard()).checks['http://127.0.0.1/'],undefined);} + finally{await db.query('DROP TRIGGER fail_record ON records');await db.query('DROP FUNCTION reject_record()');} + const resumed=(await step())!;assert.equal(resumed.status,'complete');assert.equal(resumed.checked,1);assert.equal((await dashboard()).checks['http://127.0.0.1/'].status,'network'); +}); +test('an active lease prevents two workers from processing the same queue',async()=>{ + const run=(await start(false))!;run.phase='links';run.links=['http://127.0.0.1/']; + await (await database()).query('UPDATE scan SET data=$1,lease=$2,token=$3 WHERE id=1',[JSON.stringify(run),Date.now()+60000,'another-worker']); + const unchanged=(await step()) as Run;assert.deepEqual(unchanged.links,run.links);assert.equal(unchanged.checked,0); +}); diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts new file mode 100644 index 0000000..fec1398 --- /dev/null +++ b/tests/scheduler.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {spawn} from 'node:child_process'; +import {setTimeout as sleep} from 'node:timers/promises'; +import {database} from '../lib/store.ts'; +import {DEFAULT_SCHEDULE} from '../lib/model.ts'; +import {getRun,start,dashboard} from '../lib/scanner.ts'; +import {nextOccurrence,validateSchedule,saveSchedule,getSchedule,claimScheduledRun} from '../lib/scheduler.ts'; +import {POST} from '../app/api/audit/route.ts'; + +import {useTestDatabase} from './test-database.ts'; +useTestDatabase('scheduler'); +const settings={...DEFAULT_SCHEDULE,enabled:true}; +const now=Date.parse('2026-10-29T05:00:00Z'); +async function reset(){const db=await database();await db.query('DELETE FROM records');await db.query('DELETE FROM scan');} + +test('daily and weekly times use Moscow including midnight, month rollover and exact boundary',()=>{ + assert.equal(nextOccurrence(settings,now),'2026-10-29T06:00:00.000Z'); + assert.equal(nextOccurrence(settings,Date.parse('2026-10-29T06:00:00Z')),'2026-10-30T06:00:00.000Z'); + assert.equal(nextOccurrence({...settings,time:'00:15'},Date.parse('2026-10-31T21:00:00Z')),'2026-10-31T21:15:00.000Z'); + assert.equal(nextOccurrence({...settings,frequency:'weekly',weekday:1},now),'2026-11-02T06:00:00.000Z'); +}); +test('strict validation rejects impossible times and malformed configuration',()=>{ + for(const bad of [null,{}, {...settings,time:'25:00'},{...settings,time:'9:00'},{...settings,time:'12:90'},{...settings,enabled:'true'},{...settings,force:1},{...settings,weekday:7},{...settings,weekday:1.5},{...settings,frequency:'hourly'}])assert.throws(()=>validateSchedule(bad),RangeError); +}); +test('schedule can be created, edited and disabled without dropping history',async()=>{ + await reset();assert.equal((await getSchedule()).enabled,false); + const first=await saveSchedule(settings,now);assert.equal(first.nextRunAt,'2026-10-29T06:00:00.000Z'); + const changed=await saveSchedule({...settings,time:'11:30',frequency:'weekly',weekday:5,force:true},now); + assert.equal(changed.nextRunAt,'2026-10-30T08:30:00.000Z');assert.equal((await dashboard()).schedule.force,true); + const disabled=await saveSchedule({...changed,enabled:false},now);assert.equal(disabled.nextRunAt,null);assert.equal((await getSchedule()).time,'11:30'); +}); +test('a due slot is claimed once and persists a scheduled source and cache choice',async()=>{ + await reset();await saveSchedule({...settings,force:true},now); + const due=Date.parse('2026-10-29T06:00:00Z');await Promise.all([claimScheduledRun(due),claimScheduledRun(due)]); + const run=await getRun();assert.equal(run?.source,'schedule');assert.equal(run?.force,true); + const id=run?.id;await claimScheduledRun(due);assert.equal((await getRun())?.id,id); + assert.equal((await getSchedule()).lastOutcome,'started');assert.equal((await getSchedule()).nextRunAt,'2026-10-30T06:00:00.000Z'); +}); +test('an existing manual run is preserved and the conflicting slot is skipped',async()=>{ + await reset();await saveSchedule(settings,now);const manual=await start(false); + await claimScheduledRun(Date.parse('2026-10-29T06:00:00Z')); + assert.equal((await getRun())?.id,manual?.id);assert.equal((await getRun())?.source,'manual');assert.equal((await getSchedule()).lastOutcome,'skipped'); +}); +test('downtime catches up once rather than replaying every missed day',async()=>{ + await reset();await saveSchedule(settings,now);const later=Date.parse('2026-11-05T08:00:00Z'); + await claimScheduledRun(later);const id=(await getRun())?.id;await claimScheduledRun(later); + assert.equal((await getRun())?.id,id);assert.equal((await getSchedule()).nextRunAt,'2026-11-06T06:00:00.000Z'); +}); +test('failed schedule claim rolls back both the new run and the consumed slot',async()=>{ + await reset();await saveSchedule(settings,now);const original=(await getSchedule()); + const db=await database();await db.query("CREATE FUNCTION reject_schedule() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'schedule disk failure'; END $$"); + await db.query("CREATE TRIGGER fail_schedule BEFORE UPDATE ON records FOR EACH ROW WHEN (NEW.key='schedule') EXECUTE FUNCTION reject_schedule()"); + try{await assert.rejects(claimScheduledRun(Date.parse('2026-10-29T06:00:00Z')),/schedule disk failure/);assert.equal(await getRun(),null);assert.deepEqual((await getSchedule()),original);} + finally{await db.query('DROP TRIGGER fail_schedule ON records');await db.query('DROP FUNCTION reject_schedule()');} +}); +test('schedule API rejects a foreign Origin and invalid input, then persists valid settings',async()=>{ + await reset();process.env.COURSES_ORIGIN='https://miem.space'; + const request=(origin:string,schedule:unknown)=>new Request('http://internal:3000/courses/api/audit',{method:'POST',headers:{Origin:origin,'Content-Type':'application/json'},body:JSON.stringify({action:'schedule',schedule})}); + try{assert.equal((await POST(request('https://other.example',settings))).status,403);assert.equal((await POST(request('https://miem.space',{...settings,time:'99:99'}))).status,400);assert.equal((await POST(request('https://miem.space',settings))).status,200);assert.equal((await getSchedule()).enabled,true);} + finally{delete process.env.COURSES_ORIGIN;} +}); +test('the standalone worker resumes a saved scheduled run without any browser or API client',async()=>{ + await reset();const run=(await start(false,'schedule'))!;run.phase='links';run.links=['http://127.0.0.1/'];run.totalLinks=1; + await (await database()).query('UPDATE scan SET data=$1 WHERE id=1',[JSON.stringify(run)]); + const worker=spawn(process.execPath,['--experimental-strip-types',fileURLToPath(new URL('../lib/schedule-worker.ts',import.meta.url))],{env:{...process.env},stdio:'ignore'}); + const exited=new Promise(resolve=>worker.once('exit',()=>resolve())); + try{const deadline=Date.now()+8000;while((await getRun())?.status!=='complete'&&Date.now(){ + if(!process.env.TEST_DATABASE_URL)throw new Error('Tests require a dedicated local TEST_DATABASE_URL'); + const target=new URL(process.env.TEST_DATABASE_URL); + if(!['127.0.0.1','localhost'].includes(target.hostname)||target.pathname!=='/sverka_test')throw new Error('Tests must use the isolated local sverka_test database'); + schema='sverka_test_'+label+'_'+randomUUID().replaceAll('-','');url=target.href; + const admin=new Pool({connectionString:url}); + try{await admin.query(`CREATE SCHEMA "${schema}"`);}finally{await admin.end();} + target.searchParams.set('options','-c search_path='+schema);process.env.DATABASE_URL=target.href; + }); + after(async()=>{ + await closeDatabase(); + if(!url||!schema)return; + const admin=new Pool({connectionString:url}); + try{await admin.query(`DROP SCHEMA "${schema}" CASCADE`);}finally{await admin.end();} + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c757461 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,59 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "types": [ + "node" + ], + "allowImportingTsExtensions": true + }, + "include": [ + "next-env.d.ts", + "app/**/*.ts", + "app/**/*.tsx", + "lib/model.ts", + "lib/checker.ts", + "lib/scanner.ts", + "lib/store.ts", + "tests/**/*.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "lib/scheduler.ts", + "lib/schedule-worker.ts" + ], + "exclude": [ + "node_modules", + "examples", + "dist", + ".vinext", + ".wrangler", + ".sites-runtime", + "outputs", + "work" + ] +}