feat: verify anonymous full-text access with provider evidence
This commit is contained in:
1 parent
4dbb662ec8
commit
3abe7e26ec
26 files changed
+1485
-114
No files matched your search
+6
-1
@@ -4,8 +4,10 @@ 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
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/chromium
|
||||
RUN node node_modules/playwright/cli.js install chromium --only-shell
|
||||
COPY app ./app
|
||||
COPY lib/model.ts lib/checker.ts lib/scanner.ts lib/store.ts lib/scheduler.ts lib/schedule-worker.ts lib/throttle.ts ./lib/
|
||||
COPY lib ./lib
|
||||
COPY public ./public
|
||||
COPY next.config.ts tsconfig.json postcss.config.mjs ./
|
||||
RUN pnpm build
|
||||
@@ -13,11 +15,14 @@ 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
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/chromium
|
||||
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
|
||||
COPY --from=build /opt/chromium /opt/chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 fonts-liberation fonts-noto-color-emoji && chmod -R a+rX /opt/chromium && rm -rf /var/lib/apt/lists/*
|
||||
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))"
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
Расписание: одно общее, ежедневно или еженедельно по Москве, выключено до сохранения пользователем. Запланированный аудит работает фоновым процессом при закрытом браузере и продолжается после перезапуска. Совпавший слот пропускается, если предыдущая ручная или автоматическая проверка ещё running. Изменение/отключение влияет на будущие запуски.
|
||||
|
||||
Для VPS используется отдельная база/роль на существующем PostgreSQL. Пароль только в окружении, не в исходниках.
|
||||
|
||||
Полный текст: авторизация, подписка, покупка или гостевой фрагмент означают недоступность книги с конкретным доказательством. Аннотация и успешный HTTP не дают зелёный статус. Неопределённость и остановка проверки защитой сайта показываются отдельно. Для известных ЭБС доступ проверяется после JS и перехода в читалку без авторизации. Кэш внешних ссылок имеет собственный TTL и версию правил.
|
||||
@@ -17,11 +17,23 @@ pnpm dev
|
||||
|
||||
Ручная проверка выполняется порциями, пока вкладка открыта. После её закрытия или перезапуска сервера кнопка «Продолжить» использует сохранённую очередь. Глобальная аренда не допускает одновременную обработку одной порции. Каждая порция сохраняет результаты и очередь в одной транзакции PostgreSQL; при ошибке записи задания остаются в очереди.
|
||||
|
||||
Страницы курсов загружаются заново, но литература неизменившихся курсов берётся из кэша вместе с исходной датой проверки. Полная проверка без кэша нужна для проверки изменений на внешних сайтах. HTTP200 не доказывает доступ к полному тексту; JavaScript-заглушки, CAPTCHA и признаки подписки отмечаются отдельно. Литература только во вложении отмечается как неполный охват.
|
||||
Страницы курсов сравниваются по контрольной сумме. Неизменившийся список литературы переиспользуется, но доступность внешних ссылок проверяется отдельно: кэш сутки для подтверждённых ограничений, десять минут для неопределённых результатов и временных ошибок. Версия правил входит в проверку свежести; старые HTTP-only результаты показываются как устаревшие и при следующем запуске пересчитываются. Полная проверка игнорирует кэш. Литература только во вложении отмечается как неполный охват.
|
||||
|
||||
## Доступ к полному тексту
|
||||
|
||||
Требование авторизации, подписки или покупки онлайн-доступа означает недоступность книги. Аннотация, оглавление и ознакомительный фрагмент не подтверждают доступ к полному тексту. HTTP200 и Content-Type PDF сами по себе не дают зелёный статус; при недостатке доказательств возвращается `unknown`. Даже полученный PDF может быть фрагментом издания.
|
||||
|
||||
Правила учитывают сообщения читалок Лани, Юрайта и iBooks, изъятие публикации и предложение покупки онлайн-доступа Znanium, покупку именно eBook Springer и гостевое ограничение BOOK.ru. Цена печатной книги и общий запрет скачивания PDF не доказывают закрытое чтение. Результат содержит короткое доказательство, URL без токенов, время, версию правил и профиль `anonymous`.
|
||||
|
||||
Статического HTML достаточно для явных ограничений. Неопределённые страницы известных ЭБС открываются в Chromium/Playwright; проверяется видимый текст, при необходимости адрес читалки, а у BOOK.ru — подсказка к гостевому просмотру. Для остальных сайтов неопределённость сохраняется. Проверка не выполняет вход, покупку и обход CAPTCHA.
|
||||
|
||||
Docker включает Chromium с включённой песочницей, UID1000, read-only rootfs, no-new-privileges и cap-drop ALL. Профиль seccomp разрешает необходимые для песочницы user namespaces, остальные ограничения остаются; источник и лицензия указаны в deploy/README.md. Браузер получает минимальное окружение без DATABASE_URL. Все HTTP-запросы перехватываются, DNS проверяется и закрепляется в Node-сокете. Прямой выход блокируется нерабочим proxy, WebSocket/SW отключены, непроходящий через proxy WebRTC UDP запрещён. Методы записи, внешние домены, изображения/медиа/шрифты блокируются. Лимиты:100 запросов,32MB на задачу,8MB на ресурс,10 одновременных запросов; browser deadline30s и общий link deadline60s. Ошибка/лимит оставляет доступ неподтверждённым.
|
||||
|
||||
Локальная разработка браузерной части рекомендована в Docker с тем же seccomp-профилем. `pnpm test:browser` с локальной изолированной TEST_DATABASE_URL проверяет реальный Chromium и запрет обращений к локальному HTTP/UDP серверу через fetch, redirect, iframe, popup, worker, WebSocket и WebRTC. Обычные `pnpm test` также проверяют DNS/IP pinning и правила провайдеров.
|
||||
|
||||
## Частота запросов
|
||||
|
||||
Материалы проверяются последовательно. Между запросами к одному хосту выдерживается 2 секунды, включая каталоги, курсы и перенаправления. Ограничение общее для API и worker и сохраняется в PostgreSQL. После HTTP429/503 действует пауза из `Retry-After` (не менее минуты), после HTTP403 — пять минут. Повторных попыток в цикле нет. Ссылки, пропущенные во время паузы, отмечаются как ограниченные с датой окончания паузы; это не подтверждает, что материал удалён. Их можно обновить последующей полной проверкой. Общий таймаут загрузки с перенаправлениями — 30 секунд, включая паузы. Паузы снижают нагрузку, но не гарантируют отсутствие блокировки со стороны сайта.
|
||||
Материалы проверяются последовательно. Между запросами к одному хосту выдерживается 2 секунды, включая каталоги, курсы и перенаправления. В браузерной проверке пауза применяется к навигациям; JS/CSS и anonymous API загружаются с ограничением параллелизма, без двухсекундной паузы на каждый ресурс. Ограничение общее для API и worker и сохраняется в PostgreSQL. После HTTP429/503 действует пауза из `Retry-After` (не менее минуты), после HTTP403 — пять минут. Повторных попыток в цикле нет. Ссылки, пропущенные во время паузы, отмечаются как ограниченные с датой окончания паузы; это не подтверждает, что материал удалён. Их можно обновить последующей полной проверкой. Статическая загрузка ограничена30 секундами, вся проверка ссылки с браузером —60 секундами. Proxy read timeout85s оставляет время на сохранение результата в пределах90s lease. Паузы снижают нагрузку, но не гарантируют отсутствие блокировки со стороны сайта.
|
||||
|
||||
## Расписание
|
||||
|
||||
|
||||
+3
-3
@@ -24,12 +24,12 @@ function exportCsv(){const rows=[['Программа','Курс','Ссылка
|
||||
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 <div className="shell"><a className="skip" href="#main">К результатам</a><aside className="sidebar"><a href="/courses" className="brand"><span className="brand__mark"><Link2 size={25}/></span>сверка<span className="brand__dot">.</span></a><div className="sidebar__description">Доступность знаний<br/>начинается со ссылки.</div><nav aria-label="Разделы" className="primary-nav"><button className={view==='links'?'selected':''} onClick={()=>setView('links')}><FileSearch size={18}/>Результаты проверки<span>{problems.length||'—'}</span></button><button className={view==='courses'?'selected':''} onClick={()=>setView('courses')}><BookOpen size={18}/>Учебные курсы</button></nav><div className="sidebar__label">Образовательные программы</div><nav className="program-nav" aria-label="Программы"><button className={program==='all'?'selected':''} onClick={()=>setProgram('all')}><span>Все программы</span><b>5</b></button>{PROGRAMS.map(p=><button key={p.id} className={program===p.id?'selected':''} onClick={()=>{setProgram(p.id);if(view==='method')setView('links');}}><span>{p.name}<small>{p.level}</small></span><ArrowUpRight size={15}/></button>)}</nav><div className="sidebar__bottom"><ShieldCheck size={20}/><p>Без регистрации.<br/><span>Общий реестр результатов.</span></p></div></aside><div className="workspace"><header className="topbar"><span>НИУ ВШЭ <span className="topbar__divider">/</span> Учебная литература</span><button onClick={()=>setView(view==='method'?'links':'method')}>Как это работает <ArrowUpRight size={14}/></button></header><main id="main"><div className="page-title"><div><h1>{view==='method'?'Как устроена сверка':view==='courses'?'Учебные курсы':'Ссылки под наблюдением'}</h1><p>{view==='method'?'Что проверяется и как читать результаты.':'Проверяйте литературу. Находите недоступные материалы.'}</p></div><span className="edition">5 программ<span>единый реестр</span></span></div>
|
||||
{error&&<div role="alert" className="error-banner"><span>{error}</span><button onClick={()=>{setError('');refresh().catch(e=>setError(e.message));}}>Повторить</button></div>}
|
||||
{view==='method'?<section className="method"><h2>От программы — к материалу</h2><p>Сверка читает текущий учебный год на пяти страницах ВШЭ, обходит пагинацию и открывает каждый курс. Из разделов основной и дополнительной литературы извлекаются кликабельные ссылки и адреса, записанные обычным текстом.</p><h2>Вы управляете проверкой</h2><p>Нажмите «Запустить проверку» и оставьте вкладку открытой. Можно приостановить проверку, закрыть вкладку и позже продолжить сохранённый запуск. Одновременно выполняется один общий запуск. Приостановка в этой вкладке не останавливает проверку у другого посетителя.</p><h2>Проверка по расписанию</h2><p>Настройте ежедневный или еженедельный запуск и время по Москве. Расписание общее для всех посетителей и сохраняется на сервере. Автоматическая проверка работает без браузера. Изменение или отключение расписания влияет на будущие запуски; уже начатая проверка продолжается. Если другая проверка ещё идёт, совпавший запуск пропускается. После простоя сервера выполняется один просроченный запуск, без очереди пропущенных повторений.</p><h2>Изменения вместо повторений</h2><p>Страницы курсов загружаются и сравниваются по контрольной сумме. Неизменившиеся курсы сохраняют результаты и исходные даты проверки ссылок. Одинаковые адреса в изменившихся курсах проверяются один раз за запуск. Полная проверка игнорирует кэш: используйте её, чтобы обнаружить изменения на внешних сайтах при неизменной программе курса.</p><h2>Успешный ответ — ещё не полный текст</h2><p>Различаются 404/410, вход, ограничения доступа, CAPTCHA, ошибки сервера и тайм-ауты. «Доступна» означает успешный ответ сервера. Карточка книги может открываться, а текст — требовать подписку. Ограничения определяются по признакам на странице и требуют ручного подтверждения. Сверка не обходит вход и защиту сайта.</p><h2>Границы проверки</h2><p>Проверяется литература в HTML-программах. Если раздел отсутствует, доступен только во вложении или не читается, курс получает отдельную отметку. Архивные годы и навигационные ссылки не проверяются. Старые результаты сохраняются с датами; непрочитанные и исчезнувшие курсы отмечаются как не обновлённые.</p></section>:<>
|
||||
{view==='method'?<section className="method"><h2>От программы — к материалу</h2><p>Сверка читает текущий учебный год на пяти страницах ВШЭ, обходит пагинацию и открывает каждый курс. Из разделов основной и дополнительной литературы извлекаются кликабельные ссылки и адреса, записанные обычным текстом.</p><h2>Вы управляете проверкой</h2><p>Нажмите «Запустить проверку» и оставьте вкладку открытой. Можно приостановить проверку, закрыть вкладку и позже продолжить сохранённый запуск. Одновременно выполняется один общий запуск. Приостановка в этой вкладке не останавливает проверку у другого посетителя.</p><h2>Проверка по расписанию</h2><p>Настройте ежедневный или еженедельный запуск и время по Москве. Расписание общее для всех посетителей и сохраняется на сервере. Автоматическая проверка работает без браузера. Изменение или отключение расписания влияет на будущие запуски; уже начатая проверка продолжается. Если другая проверка ещё идёт, совпавший запуск пропускается. После простоя сервера выполняется один просроченный запуск, без очереди пропущенных повторений.</p><h2>Изменения вместо повторений</h2><p>Страницы курсов сравниваются по контрольной сумме. Неизменившийся список литературы используется повторно. Доступность ссылок имеет отдельный срок кэша: сутки для подтверждённых ограничений и десять минут для неопределённых результатов или временных ошибок. После изменения правил ссылки проверяются заново. Полная проверка игнорирует кэш.</p><h2>Проверяется доступ без авторизации</h2><p>Различаются 404/410, вход, ограничения доступа, CAPTCHA, ошибки сервера и тайм-ауты. Если для чтения требуется авторизация, подписка или покупка, книга считается недоступной. Причина подтверждается сообщением сайта или предложением покупки онлайн-доступа. Карточка, аннотация и фрагмент не подтверждают доступ ко всей книге. Для поддерживаемых ЭБС проверяется содержимое после загрузки JavaScript и, при необходимости, читалка. При недостатке доказательств показывается «Доступ не подтверждён». Сверка не обходит вход и защиту сайта.</p><h2>Границы проверки</h2><p>Проверяется литература в HTML-программах. Если раздел отсутствует, доступен только во вложении или не читается, курс получает отдельную отметку. Архивные годы и навигационные ссылки не проверяются. Старые результаты сохраняются с датами; непрочитанные и исчезнувшие курсы отмечаются как не обновлённые.</p></section>:<>
|
||||
<section className="control" aria-label="Управление проверкой"><div className="control__icon"><Link2 size={25}/></div><div className="control__copy"><h2>{serverActive?'Выполняется проверка по расписанию':active?'Сверяем источники':run?.status==='running'?'Проверка ожидает продолжения':run?'Результаты сохранены':'Начните с первой проверки'}</h2><p aria-live="polite">{active||serverActive?run?.message:run?`Последний запуск: ${date(run.startedAt)} · МСК`:'Все курсы пяти программ. Только реальные результаты.'}</p></div><div className="control__actions">{serverActive?<button disabled className="button button--primary"><LoaderCircle size={16} className="spin"/>Выполняется на сервере</button>:active?<button className="button button--primary" onClick={pause}><Pause size={16}/>Приостановить</button>:<button disabled={loading} className="button button--primary" onClick={launch}>{loading?<LoaderCircle size={16} className="spin"/>:<Play size={16}/>} {run?.status==='running'?'Продолжить проверку':'Запустить проверку'}</button>}<label className="force"><input type="checkbox" checked={force} disabled={active||run?.status==='running'} onChange={e=>setForce(e.target.checked)}/>Полная проверка без кэша</label></div>{run?.status==='running'&&<div className="scan-progress"><div><span>{run.phase==='catalogs'?`Каталоги: ${run.visited.length}`:run.phase==='courses'?`Курсы: ${run.processed} / ${run.totalCourses}`:`Адреса: ${run.checked} / ${run.totalLinks}`}</span><span>{serverActive?'Выполняется без открытой вкладки':'Оставьте вкладку открытой'}</span></div><progress aria-label="Ход проверки" max={100} value={run.phase==='catalogs'?undefined:percent}/></div>}</section>
|
||||
{loaded?<SchedulePanel schedule={data.schedule} onSaved={schedule=>setData(d=>({...d,schedule}))}/>:<p className="schedule__hint" role="status">{loading?'Загружаем настройки расписания…':'Расписание недоступно. Повторите загрузку через сообщение об ошибке выше.'}</p>}<div className="summary" aria-label="Сводка"><div><span className="summary__number summary__number--problem">{problems.length||'—'}</span><span>ссылок требуют внимания</span></div><div><span className="summary__number">{courses.length||'—'}</span><span>курсов в реестре</span></div><div><span className="summary__number">{rows.filter(r=>r.result?.status==='ok').length||'—'}</span><span>ссылок доступны</span></div><div><span className="summary__number">{run?.reused??'—'}</span><span>курсов взято из кэша</span></div></div>
|
||||
{loaded?<SchedulePanel schedule={data.schedule} onSaved={schedule=>setData(d=>({...d,schedule}))}/>:<p className="schedule__hint" role="status">{loading?'Загружаем настройки расписания…':'Расписание недоступно. Повторите загрузку через сообщение об ошибке выше.'}</p>}<div className="summary" aria-label="Сводка"><div><span className="summary__number summary__number--problem">{problems.length||'—'}</span><span>ссылок требуют внимания</span></div><div><span className="summary__number">{courses.length||'—'}</span><span>курсов в реестре</span></div><div><span className="summary__number">{rows.filter(r=>r.result?.status==='ok').length||'—'}</span><span>полных текстов доступны</span></div><div><span className="summary__number">{run?.reused??'—'}</span><span>курсов взято из кэша</span></div></div>
|
||||
{run&&run.errors.length>0&&<details className="source-errors"><summary>Не удалось прочитать {run.errors.length} источников — охват неполный</summary>{run.errors.map((e,i)=><p key={i}><a href={e.url.startsWith('https://')?e.url:undefined} target="_blank" rel="noreferrer">{e.url}</a><br/>{e.reason}</p>)}</details>}
|
||||
<section className="results"><div className="results__heading"><h2>{view==='courses'?'Каталог курсов':'Результаты проверки'}<span>{view==='courses'?shownCourses.length:visible.length}</span></h2><button className="text-button" onClick={exportCsv} disabled={!visible.length}><Download size={16}/>Экспорт CSV</button></div><div className="filters"><label className="search"><Search size={18}/><input aria-label="Поиск по курсу, материалу или адресу" placeholder="Найти курс, материал или ссылку" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button aria-label="Очистить поиск" onClick={()=>setQuery('')}><X size={16}/></button>}</label>{view==='links'&&<label className="status-select"><SlidersHorizontal size={16}/><select aria-label="Причина недоступности" value={status} onChange={e=>setStatus(e.target.value)}><option value="issues">Требуют внимания</option><option value="all">Все ссылки</option>{Object.entries(LABELS).map(([k,v])=><option value={k} key={k}>{v}</option>)}<option value="pending">Не проверены</option></select><ChevronDown size={14}/></label>}</div>{selected&&<div className="scope">{selected.name}<button aria-label="Сбросить программу" onClick={()=>setProgram('all')}><X size={14}/></button><a href={selected.url} target="_blank" rel="noreferrer">Каталог ВШЭ <ExternalLink size={13}/></a></div>}
|
||||
{loading?<div className="empty" role="status"><LoaderCircle className="spin" size={30}/><h3>Загружаем реестр</h3></div>:view==='courses'?shownCourses.length?<div className="course-list">{shownCourses.slice(0,limit).map(c=><article key={c.url}><div><a className="course-name" href={c.url+'#library'} target="_blank" rel="noreferrer">{c.name}<ArrowUpRight size={16}/></a><p>{PROGRAMS.find(p=>p.id===c.program)?.name}</p>{c.issue&&<p className="course-issue">{c.issue}</p>}<small>{date(c.checkedAt)} · {c.refs.length} адресов {c.reused?'· из кэша':''}{run&&c.seen!==run.id?' · не обновлён в текущем запуске':''}</small></div><span className={'badge '+(c.issue?'badge--blocked':'badge--ok')}>{c.issue?'Нужна проверка':c.refs.length?'Литература найдена':'Нет ссылок'}</span></article>)}</div>:<Empty hasRun={!!run} filtered={!!query||program!=='all'} launch={launch}/>:visible.length?<><div className="table-head"><span>Материал и источник</span><span>Результат</span><span>Проверено, МСК</span></div><div className="link-list">{visible.slice(0,limit).map(r=>{const key=r.course.url+r.ref.url;return <article key={key} className="link-row"><div className="link-row__main"><span className={'status-dot status-dot--'+(r.result?.status??'pending')}/><div><a className="material-name" href={r.ref.url} target="_blank" rel="noreferrer">{r.ref.title||r.ref.url}<ArrowUpRight size={15}/></a><div className="material-url">{r.ref.url}</div><a className="source-course" href={r.course.url+'#library'} target="_blank" rel="noreferrer"><BookOpen size={13}/>{r.course.name}<ArrowUpRight size={12}/></a><small className="program-name">{PROGRAMS.find(p=>p.id===r.course.program)?.name}{run&&r.course.seen!==run.id?' · прежний результат':''}</small></div></div><div className="link-row__result"><span className={'badge badge--'+(r.result?.status??'pending')}>{r.result?LABELS[r.result.status]:'Не проверено'}</span><button className="reason-toggle" aria-expanded={expanded===key} onClick={()=>setExpanded(expanded===key?'':key)}>Причина <ChevronDown size={12}/></button></div><div className="link-row__date">{date(r.result?.checkedAt)}{r.result?.code&&<small>HTTP {r.result.code}</small>}</div>{expanded===key&&<div className="reason"><p>{r.result?.reason??'Адрес ещё не проверен. Запустите или продолжите сверку.'}</p>{r.result?.finalUrl&&r.result.finalUrl!==r.ref.url&&<a href={r.result.finalUrl} target="_blank" rel="noreferrer">После перенаправления: {r.result.finalUrl}</a>}{r.course.reused&&<small>Содержание курса не изменилось. Использован сохранённый результат.</small>}</div>}</article>;})}</div></>:<Empty hasRun={!!run} filtered={!!query||program!=='all'||status!=='issues'} launch={launch}/>}
|
||||
{loading?<div className="empty" role="status"><LoaderCircle className="spin" size={30}/><h3>Загружаем реестр</h3></div>:view==='courses'?shownCourses.length?<div className="course-list">{shownCourses.slice(0,limit).map(c=><article key={c.url}><div><a className="course-name" href={c.url+'#library'} target="_blank" rel="noreferrer">{c.name}<ArrowUpRight size={16}/></a><p>{PROGRAMS.find(p=>p.id===c.program)?.name}</p>{c.issue&&<p className="course-issue">{c.issue}</p>}<small>{date(c.checkedAt)} · {c.refs.length} адресов {c.reused?'· из кэша':''}{run&&c.seen!==run.id?' · не обновлён в текущем запуске':''}</small></div><span className={'badge '+(c.issue?'badge--blocked':'badge--ok')}>{c.issue?'Нужна проверка':c.refs.length?'Литература найдена':'Нет ссылок'}</span></article>)}</div>:<Empty hasRun={!!run} filtered={!!query||program!=='all'} launch={launch}/>:visible.length?<><div className="table-head"><span>Материал и источник</span><span>Результат</span><span>Проверено, МСК</span></div><div className="link-list">{visible.slice(0,limit).map(r=>{const key=r.course.url+r.ref.url;return <article key={key} className="link-row"><div className="link-row__main"><span className={'status-dot status-dot--'+(r.result?.status??'pending')}/><div><a className="material-name" href={r.ref.url} target="_blank" rel="noreferrer">{r.ref.title||r.ref.url}<ArrowUpRight size={15}/></a><div className="material-url">{r.ref.url}</div><a className="source-course" href={r.course.url+'#library'} target="_blank" rel="noreferrer"><BookOpen size={13}/>{r.course.name}<ArrowUpRight size={12}/></a><small className="program-name">{PROGRAMS.find(p=>p.id===r.course.program)?.name}{run&&r.course.seen!==run.id?' · прежний результат':''}</small></div></div><div className="link-row__result"><span className={'badge badge--'+(r.result?.status??'pending')}>{r.result?LABELS[r.result.status]:'Не проверено'}</span>{r.result&&['auth','restricted'].includes(r.result.status)&&<p className="link-row__reason">{r.result.reason}</p>}<button className="reason-toggle" aria-expanded={expanded===key} onClick={()=>setExpanded(expanded===key?'':key)}>Причина <ChevronDown size={12}/></button></div><div className="link-row__date">{date(r.result?.checkedAt)}</div>{expanded===key&&<div className="reason"><p>{r.result?.reason??'Адрес ещё не проверен. Запустите или продолжите сверку.'}</p>{r.result?.evidence&&<div className="reason__evidence"><blockquote>{r.result.evidence.text}</blockquote><a href={r.result.evidence.url} target="_blank" rel="noreferrer">Сообщение на сайте <ExternalLink size={12}/></a><small>Проверено без авторизации</small></div>}{r.result?.code&&<small>Технические сведения: HTTP {r.result.code}</small>}{r.result?.finalUrl&&r.result.finalUrl!==r.ref.url&&<a href={r.result.finalUrl} target="_blank" rel="noreferrer">После перенаправления: {r.result.finalUrl}</a>}{r.course.reused&&<small>Список литературы курса не изменился. Доступность ссылок проверяется отдельно.</small>}</div>}</article>;})}</div></>:<Empty hasRun={!!run} filtered={!!query||program!=='all'||status!=='issues'} launch={launch}/>}
|
||||
{(view==='courses'?shownCourses.length:visible.length)>limit&&<button className="load-more" onClick={()=>setLimit(n=>n+40)}>Показать ещё 40</button>}<div className="results__note"><ShieldCheck size={15}/><span>Блокировка проверки не означает, что материал удалён. Причину можно уточнить по исходной ссылке.</span></div></section><div className="bottom-note"><span>Проверяем изменения, сохраняем результаты.</span><span><RefreshCw size={13}/>Неизменившиеся курсы — из кэша</span></div></>}
|
||||
<footer><span>сверка. <span>Независимый инструмент · источники: hse.ru</span></span><span>Интерфейс {VERSION} · Сервер {VERSION}</span></footer></main></div></div>;}
|
||||
function Empty({hasRun,filtered,launch}:{hasRun:boolean;filtered:boolean;launch:()=>void}){return <div className="empty"><div className="empty__drawing"><FileSearch size={43} strokeWidth={1.15}/><span><Check size={15}/></span></div><h3>{filtered?'По этим условиям ничего нет':hasRun?'Проблемных ссылок пока нет':'Пока ни одна ссылка не проверена'}</h3><p>{filtered?'Измените запрос, причину или выбранную программу.':hasRun?'Смотрите охват и список курсов. Непрочитанные источники отмечаются отдельно.':'Запустите сверку, чтобы найти недоступную литературу и увидеть, в каких курсах она используется.'}</p>{!hasRun&&!filtered&&<button className="text-button" onClick={launch}>Проверить пять программ<ArrowUpRight size={16}/></button>}<div className="empty__legend"><span><i className="status-dot status-dot--broken"/>404 / удалена</span><span><i className="status-dot status-dot--auth"/>Требуется вход</span><span><i className="status-dot status-dot--blocked"/>Ограничен доступ</span></div></div>;}
|
||||
+4
-2
@@ -4,5 +4,7 @@
|
||||
@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%}}
|
||||
@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%}}
|
||||
|
||||
.link-row__reason{font-size:10px;line-height:1.6;margin:6px 0 0;color:var(--ink);overflow-wrap:anywhere}.reason__evidence{margin-top:12px}.reason__evidence blockquote{margin:0;padding:10px 12px;border:1px solid var(--line);border-radius:4px;background:var(--paper);font-size:11px;line-height:1.7}
|
||||
+5
-3
@@ -2,7 +2,7 @@ name: miem-courses
|
||||
services:
|
||||
courses:
|
||||
build: .
|
||||
image: miem-courses:1.2.2
|
||||
image: miem-courses:1.3.0
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "1000:1000"
|
||||
@@ -17,12 +17,14 @@ services:
|
||||
- database
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
- /tmp:size=128m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 512m
|
||||
- seccomp=./deploy/chromium-seccomp.json
|
||||
shm_size: 128m
|
||||
mem_limit: 1g
|
||||
cpus: 1.0
|
||||
networks:
|
||||
database:
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Chromium sandbox
|
||||
|
||||
chromium-seccomp.json — неизменённый профиль Microsoft Playwright v1.64.1:
|
||||
https://github.com/microsoft/playwright/blob/v1.64.1/utils/docker/seccomp_profile.json
|
||||
|
||||
Лицензия Apache-2.0 сохранена в PLAYWRIGHT-LICENSE. Профиль разрешает clone/setns/unshare для Chromium user namespaces и используется с chromiumSandbox:true, UID1000, no-new-privileges и cap-drop ALL. Не заменять seccomp на unconfined или Chromium sandbox на --no-sandbox. Read-only rootfs и временные /tmp,/dev/shm сохраняются.
|
||||
|
||||
Nginx-сниппет повышает read timeout только для /courses до85s, чтобы browser deadline60s и сохранение данных завершались до90s lease. Другие маршруты и сервисы не меняются.
|
||||
@@ -0,0 +1,831 @@
|
||||
{
|
||||
"defaultAction": "SCMP_ACT_ERRNO",
|
||||
"archMap": [
|
||||
{
|
||||
"architecture": "SCMP_ARCH_X86_64",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_X86",
|
||||
"SCMP_ARCH_X32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_AARCH64",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_ARM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_MIPS64",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_MIPS",
|
||||
"SCMP_ARCH_MIPS64N32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_MIPS64N32",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_MIPS",
|
||||
"SCMP_ARCH_MIPS64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_MIPSEL64",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_MIPSEL",
|
||||
"SCMP_ARCH_MIPSEL64N32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_MIPSEL64N32",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_MIPSEL",
|
||||
"SCMP_ARCH_MIPSEL64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"architecture": "SCMP_ARCH_S390X",
|
||||
"subArchitectures": [
|
||||
"SCMP_ARCH_S390"
|
||||
]
|
||||
}
|
||||
],
|
||||
"syscalls": [
|
||||
{
|
||||
"comment": "Allow create user namespaces",
|
||||
"names": [
|
||||
"clone",
|
||||
"setns",
|
||||
"unshare"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"accept",
|
||||
"accept4",
|
||||
"access",
|
||||
"adjtimex",
|
||||
"alarm",
|
||||
"bind",
|
||||
"brk",
|
||||
"capget",
|
||||
"capset",
|
||||
"chdir",
|
||||
"chmod",
|
||||
"chown",
|
||||
"chown32",
|
||||
"clock_adjtime",
|
||||
"clock_adjtime64",
|
||||
"clock_getres",
|
||||
"clock_getres_time64",
|
||||
"clock_gettime",
|
||||
"clock_gettime64",
|
||||
"clock_nanosleep",
|
||||
"clock_nanosleep_time64",
|
||||
"close",
|
||||
"connect",
|
||||
"copy_file_range",
|
||||
"creat",
|
||||
"dup",
|
||||
"dup2",
|
||||
"dup3",
|
||||
"epoll_create",
|
||||
"epoll_create1",
|
||||
"epoll_ctl",
|
||||
"epoll_ctl_old",
|
||||
"epoll_pwait",
|
||||
"epoll_wait",
|
||||
"epoll_wait_old",
|
||||
"eventfd",
|
||||
"eventfd2",
|
||||
"execve",
|
||||
"execveat",
|
||||
"exit",
|
||||
"exit_group",
|
||||
"faccessat",
|
||||
"fadvise64",
|
||||
"fadvise64_64",
|
||||
"fallocate",
|
||||
"fanotify_mark",
|
||||
"fchdir",
|
||||
"fchmod",
|
||||
"fchmodat",
|
||||
"fchown",
|
||||
"fchown32",
|
||||
"fchownat",
|
||||
"fcntl",
|
||||
"fcntl64",
|
||||
"fdatasync",
|
||||
"fgetxattr",
|
||||
"flistxattr",
|
||||
"flock",
|
||||
"fork",
|
||||
"fremovexattr",
|
||||
"fsetxattr",
|
||||
"fstat",
|
||||
"fstat64",
|
||||
"fstatat64",
|
||||
"fstatfs",
|
||||
"fstatfs64",
|
||||
"fsync",
|
||||
"ftruncate",
|
||||
"ftruncate64",
|
||||
"futex",
|
||||
"futex_time64",
|
||||
"futimesat",
|
||||
"getcpu",
|
||||
"getcwd",
|
||||
"getdents",
|
||||
"getdents64",
|
||||
"getegid",
|
||||
"getegid32",
|
||||
"geteuid",
|
||||
"geteuid32",
|
||||
"getgid",
|
||||
"getgid32",
|
||||
"getgroups",
|
||||
"getgroups32",
|
||||
"getitimer",
|
||||
"getpeername",
|
||||
"getpgid",
|
||||
"getpgrp",
|
||||
"getpid",
|
||||
"getppid",
|
||||
"getpriority",
|
||||
"getrandom",
|
||||
"getresgid",
|
||||
"getresgid32",
|
||||
"getresuid",
|
||||
"getresuid32",
|
||||
"getrlimit",
|
||||
"get_robust_list",
|
||||
"getrusage",
|
||||
"getsid",
|
||||
"getsockname",
|
||||
"getsockopt",
|
||||
"get_thread_area",
|
||||
"gettid",
|
||||
"gettimeofday",
|
||||
"getuid",
|
||||
"getuid32",
|
||||
"getxattr",
|
||||
"inotify_add_watch",
|
||||
"inotify_init",
|
||||
"inotify_init1",
|
||||
"inotify_rm_watch",
|
||||
"io_cancel",
|
||||
"ioctl",
|
||||
"io_destroy",
|
||||
"io_getevents",
|
||||
"io_pgetevents",
|
||||
"io_pgetevents_time64",
|
||||
"ioprio_get",
|
||||
"ioprio_set",
|
||||
"io_setup",
|
||||
"io_submit",
|
||||
"io_uring_enter",
|
||||
"io_uring_register",
|
||||
"io_uring_setup",
|
||||
"ipc",
|
||||
"kill",
|
||||
"lchown",
|
||||
"lchown32",
|
||||
"lgetxattr",
|
||||
"link",
|
||||
"linkat",
|
||||
"listen",
|
||||
"listxattr",
|
||||
"llistxattr",
|
||||
"_llseek",
|
||||
"lremovexattr",
|
||||
"lseek",
|
||||
"lsetxattr",
|
||||
"lstat",
|
||||
"lstat64",
|
||||
"madvise",
|
||||
"membarrier",
|
||||
"memfd_create",
|
||||
"mincore",
|
||||
"mkdir",
|
||||
"mkdirat",
|
||||
"mknod",
|
||||
"mknodat",
|
||||
"mlock",
|
||||
"mlock2",
|
||||
"mlockall",
|
||||
"mmap",
|
||||
"mmap2",
|
||||
"mprotect",
|
||||
"mq_getsetattr",
|
||||
"mq_notify",
|
||||
"mq_open",
|
||||
"mq_timedreceive",
|
||||
"mq_timedreceive_time64",
|
||||
"mq_timedsend",
|
||||
"mq_timedsend_time64",
|
||||
"mq_unlink",
|
||||
"mremap",
|
||||
"msgctl",
|
||||
"msgget",
|
||||
"msgrcv",
|
||||
"msgsnd",
|
||||
"msync",
|
||||
"munlock",
|
||||
"munlockall",
|
||||
"munmap",
|
||||
"nanosleep",
|
||||
"newfstatat",
|
||||
"_newselect",
|
||||
"open",
|
||||
"openat",
|
||||
"pause",
|
||||
"pipe",
|
||||
"pipe2",
|
||||
"poll",
|
||||
"ppoll",
|
||||
"ppoll_time64",
|
||||
"prctl",
|
||||
"pread64",
|
||||
"preadv",
|
||||
"preadv2",
|
||||
"prlimit64",
|
||||
"pselect6",
|
||||
"pselect6_time64",
|
||||
"pwrite64",
|
||||
"pwritev",
|
||||
"pwritev2",
|
||||
"read",
|
||||
"readahead",
|
||||
"readlink",
|
||||
"readlinkat",
|
||||
"readv",
|
||||
"recv",
|
||||
"recvfrom",
|
||||
"recvmmsg",
|
||||
"recvmmsg_time64",
|
||||
"recvmsg",
|
||||
"remap_file_pages",
|
||||
"removexattr",
|
||||
"rename",
|
||||
"renameat",
|
||||
"renameat2",
|
||||
"restart_syscall",
|
||||
"rmdir",
|
||||
"rseq",
|
||||
"rt_sigaction",
|
||||
"rt_sigpending",
|
||||
"rt_sigprocmask",
|
||||
"rt_sigqueueinfo",
|
||||
"rt_sigreturn",
|
||||
"rt_sigsuspend",
|
||||
"rt_sigtimedwait",
|
||||
"rt_sigtimedwait_time64",
|
||||
"rt_tgsigqueueinfo",
|
||||
"sched_getaffinity",
|
||||
"sched_getattr",
|
||||
"sched_getparam",
|
||||
"sched_get_priority_max",
|
||||
"sched_get_priority_min",
|
||||
"sched_getscheduler",
|
||||
"sched_rr_get_interval",
|
||||
"sched_rr_get_interval_time64",
|
||||
"sched_setaffinity",
|
||||
"sched_setattr",
|
||||
"sched_setparam",
|
||||
"sched_setscheduler",
|
||||
"sched_yield",
|
||||
"seccomp",
|
||||
"select",
|
||||
"semctl",
|
||||
"semget",
|
||||
"semop",
|
||||
"semtimedop",
|
||||
"semtimedop_time64",
|
||||
"send",
|
||||
"sendfile",
|
||||
"sendfile64",
|
||||
"sendmmsg",
|
||||
"sendmsg",
|
||||
"sendto",
|
||||
"setfsgid",
|
||||
"setfsgid32",
|
||||
"setfsuid",
|
||||
"setfsuid32",
|
||||
"setgid",
|
||||
"setgid32",
|
||||
"setgroups",
|
||||
"setgroups32",
|
||||
"setitimer",
|
||||
"setpgid",
|
||||
"setpriority",
|
||||
"setregid",
|
||||
"setregid32",
|
||||
"setresgid",
|
||||
"setresgid32",
|
||||
"setresuid",
|
||||
"setresuid32",
|
||||
"setreuid",
|
||||
"setreuid32",
|
||||
"setrlimit",
|
||||
"set_robust_list",
|
||||
"setsid",
|
||||
"setsockopt",
|
||||
"set_thread_area",
|
||||
"set_tid_address",
|
||||
"setuid",
|
||||
"setuid32",
|
||||
"setxattr",
|
||||
"shmat",
|
||||
"shmctl",
|
||||
"shmdt",
|
||||
"shmget",
|
||||
"shutdown",
|
||||
"sigaltstack",
|
||||
"signalfd",
|
||||
"signalfd4",
|
||||
"sigprocmask",
|
||||
"sigreturn",
|
||||
"socket",
|
||||
"socketcall",
|
||||
"socketpair",
|
||||
"splice",
|
||||
"stat",
|
||||
"stat64",
|
||||
"statfs",
|
||||
"statfs64",
|
||||
"statx",
|
||||
"symlink",
|
||||
"symlinkat",
|
||||
"sync",
|
||||
"sync_file_range",
|
||||
"syncfs",
|
||||
"sysinfo",
|
||||
"tee",
|
||||
"tgkill",
|
||||
"time",
|
||||
"timer_create",
|
||||
"timer_delete",
|
||||
"timer_getoverrun",
|
||||
"timer_gettime",
|
||||
"timer_gettime64",
|
||||
"timer_settime",
|
||||
"timer_settime64",
|
||||
"timerfd_create",
|
||||
"timerfd_gettime",
|
||||
"timerfd_gettime64",
|
||||
"timerfd_settime",
|
||||
"timerfd_settime64",
|
||||
"times",
|
||||
"tkill",
|
||||
"truncate",
|
||||
"truncate64",
|
||||
"ugetrlimit",
|
||||
"umask",
|
||||
"uname",
|
||||
"unlink",
|
||||
"unlinkat",
|
||||
"utime",
|
||||
"utimensat",
|
||||
"utimensat_time64",
|
||||
"utimes",
|
||||
"vfork",
|
||||
"vmsplice",
|
||||
"wait4",
|
||||
"waitid",
|
||||
"waitpid",
|
||||
"write",
|
||||
"writev"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"ptrace"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": null,
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"minKernel": "4.8"
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"personality"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 0,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"personality"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 8,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"personality"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 131072,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"personality"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 131080,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"personality"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 4294967295,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"sync_file_range2"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"ppc64le"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"arm_fadvise64_64",
|
||||
"arm_sync_file_range",
|
||||
"sync_file_range2",
|
||||
"breakpoint",
|
||||
"cacheflush",
|
||||
"set_tls"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"arm",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"arch_prctl"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"amd64",
|
||||
"x32"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"modify_ldt"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"amd64",
|
||||
"x32",
|
||||
"x86"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"s390_pci_mmio_read",
|
||||
"s390_pci_mmio_write",
|
||||
"s390_runtime_instr"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"s390",
|
||||
"s390x"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"open_by_handle_at"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_DAC_READ_SEARCH"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"bpf",
|
||||
"clone",
|
||||
"fanotify_init",
|
||||
"lookup_dcookie",
|
||||
"mount",
|
||||
"name_to_handle_at",
|
||||
"perf_event_open",
|
||||
"quotactl",
|
||||
"setdomainname",
|
||||
"sethostname",
|
||||
"setns",
|
||||
"syslog",
|
||||
"umount",
|
||||
"umount2",
|
||||
"unshare"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_ADMIN"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"clone"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 2114060288,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_MASKED_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "",
|
||||
"includes": {},
|
||||
"excludes": {
|
||||
"caps": [
|
||||
"CAP_SYS_ADMIN"
|
||||
],
|
||||
"arches": [
|
||||
"s390",
|
||||
"s390x"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"clone"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [
|
||||
{
|
||||
"index": 1,
|
||||
"value": 2114060288,
|
||||
"valueTwo": 0,
|
||||
"op": "SCMP_CMP_MASKED_EQ"
|
||||
}
|
||||
],
|
||||
"comment": "s390 parameter ordering for clone is different",
|
||||
"includes": {
|
||||
"arches": [
|
||||
"s390",
|
||||
"s390x"
|
||||
]
|
||||
},
|
||||
"excludes": {
|
||||
"caps": [
|
||||
"CAP_SYS_ADMIN"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"reboot"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_BOOT"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"chroot"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_CHROOT"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"delete_module",
|
||||
"init_module",
|
||||
"finit_module"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_MODULE"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"acct"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_PACCT"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"kcmp",
|
||||
"process_vm_readv",
|
||||
"process_vm_writev",
|
||||
"ptrace"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_PTRACE"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"iopl",
|
||||
"ioperm"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_RAWIO"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"settimeofday",
|
||||
"stime",
|
||||
"clock_settime"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_TIME"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"vhangup"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_TTY_CONFIG"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"get_mempolicy",
|
||||
"mbind",
|
||||
"set_mempolicy"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYS_NICE"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
},
|
||||
{
|
||||
"names": [
|
||||
"syslog"
|
||||
],
|
||||
"action": "SCMP_ACT_ALLOW",
|
||||
"args": [],
|
||||
"comment": "",
|
||||
"includes": {
|
||||
"caps": [
|
||||
"CAP_SYSLOG"
|
||||
]
|
||||
},
|
||||
"excludes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ location = /courses {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 35s;
|
||||
proxy_read_timeout 85s;
|
||||
}
|
||||
location ^~ /courses/ {
|
||||
proxy_pass http://127.0.0.1:3011;
|
||||
@@ -13,5 +13,5 @@ location ^~ /courses/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 35s;
|
||||
proxy_read_timeout 85s;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {parseHTML} from 'linkedom';
|
||||
import type {Check} from './model.ts';
|
||||
|
||||
export const RULES_VERSION='access-1';
|
||||
export const CHECK_TTL=24*60*60*1000;
|
||||
export function checkCurrent(check:Check|undefined,now=Date.now()){
|
||||
return !!check&&check.rulesVersion===RULES_VERSION&&check.profile==='anonymous'&&now-Date.parse(check.checkedAt)<(['unknown','blocked','network','timeout'].includes(check.status)?600000:CHECK_TTL);
|
||||
}
|
||||
export function evidenceUrl(raw:string){const u=new URL(raw);for(const key of [...u.searchParams.keys()])if(!['pid','id','bcode'].includes(key))u.searchParams.delete(key);u.hash='';return u.href;}
|
||||
export function result(url:string,status:Check['status'],reason:string,extra:Partial<Check>={}):Check{
|
||||
return {url,status,reason,checkedAt:new Date().toISOString(),rulesVersion:RULES_VERSION,profile:'anonymous',...extra};
|
||||
}
|
||||
function evidence(url:string,text:string,rule:string){const normalized=text.replace(/\s+/g,' ').trim(),at=normalized.search(/публикация\s+изъята|для\s+(?:получения\s+)?(?:доступа|чтения|просмотра)|чтобы\s+получить\s+полный\s+доступ|sign in to|чтение\s+книги\s+недоступно|книга\s+недоступна/i);return {text:normalized.slice(Math.max(0,at),Math.max(0,at)+500),url:evidenceUrl(url),rule};}
|
||||
export function accessMessage(url:string,text:string):Check|null{
|
||||
const normalized=text.replace(/\s+/g,' ').trim();
|
||||
const preview=/доступн[а-я]*\s+(?:только\s+|первые\s+)?(\d{1,4})\s+страниц/i.exec(normalized);
|
||||
if(preview&&/гостев|ознакомительн|фрагмент/i.test(normalized))return result(url,'restricted',`Доступен только ознакомительный фрагмент — до ${preview[1]} страниц; полный текст недоступен.`,{evidence:evidence(url,normalized.slice(Math.max(0,preview.index-150),preview.index+200),'reader-preview-pages')});
|
||||
if(/публикация\s+изъята\s+из\s+фонда/i.test(normalized))return result(url,'broken','Публикация изъята из фонда.',{evidence:evidence(url,normalized,'publication-withdrawn')});
|
||||
if(/\/bookshelf\/\d+\/reading/.test(new URL(url).pathname)&&/для продолжения работы с сайтом.{0,60}войдите в систему/i.test(normalized))return result(url,'auth','Для чтения книги требуется авторизация.',{evidence:evidence(url,normalized,'ibooks-reader-login')});
|
||||
const subscription=/(?:для\s+(?:полного\s+)?(?:доступа|чтения)|чтобы\s+(?:получить\s+)?полный\s+доступ|полный\s+текст)[^.]{0,250}(?:подписк|оплат|купить)|доступ\s+(?:к\s+полному\s+тексту\s+)?по\s+подписке|читать\s+по\s+подписке|sign in to (?:read|access)[^.]{0,100}subscri/i.test(normalized);
|
||||
const login=/(?:для\s+(?:получения\s+)?(?:доступа\s+к\s+)?(?:чтения|просмотра|доступа)|чтобы\s+(?:получить\s+)?полный\s+доступ)[^.]{0,220}(?:авториз|войти|войдите|вход)|(?:авторизуйтесь|войдите)[^.]{0,100}(?:чтения|полного\s+доступа|чита)|sign in to (?:read|access|view)/i.test(normalized);
|
||||
if(login||subscription)return result(url,subscription?'restricted':'auth',login&&subscription?'Для чтения книги требуются авторизация и подписка.':login?'Для чтения книги требуется авторизация.':'Для чтения книги требуется подписка или оплата.',{evidence:evidence(url,normalized,login&&subscription?'reader-login-subscription':login?'reader-login':'reader-subscription')});
|
||||
if(/(?:доступен|доступно)\s+только\s+(?:ознакомительный\s+)?фрагмент|только\s+ознакомительный\s+фрагмент/i.test(normalized))return result(url,'restricted','Доступен только ознакомительный фрагмент; полный текст недоступен.',{evidence:evidence(url,normalized,'reader-preview')});
|
||||
if(/книга\s+недоступна\s+для\s+чтения|чтение\s+книги\s+недоступно/i.test(normalized))return result(url,'restricted','Чтение книги недоступно. Сайт не указал подтверждённую причину.',{evidence:evidence(url,normalized,'reader-denied')});
|
||||
return null;
|
||||
}
|
||||
export function purchaseOffer(url:string,html:string):Check|null{
|
||||
const {document}=parseHTML(html),host=new URL(url).hostname;
|
||||
document.querySelectorAll('script,style,[hidden],[aria-hidden="true"],.hidden,[style*="display: none"],[style*="display:none"]').forEach(n=>n.remove());
|
||||
if(host==='znanium.ru'||host.endsWith('.znanium.ru')){
|
||||
const id=new URL(url).searchParams.get('id');
|
||||
for(const item of document.querySelectorAll('.book-offers__item')){
|
||||
const text=item.textContent??'',control=item.querySelector('.book-offers__ctrl');
|
||||
if(/доступ\s+онлайн/i.test(text)&&/[1-9]\d*(?:[.,]\d+)?\s*₽/.test(text)&&/в корзину/i.test(control?.textContent??'')&&(!id||control?.getAttribute('data-id')===id))return result(url,'restricted','Для чтения книги требуется покупка онлайн-доступа или подписка.',{evidence:evidence(url,text,'znanium-online-purchase')});
|
||||
}
|
||||
}
|
||||
if(/(^|\.)(springer\.com|springernature\.com)$/.test(host)){
|
||||
for(const access of document.querySelectorAll('.app-article-access')){
|
||||
if(/this is an open access book|open access/i.test(access.textContent??''))continue;
|
||||
for(const option of access.querySelectorAll('.buying-option')){
|
||||
if(/ebook/i.test(option.textContent??'')&&/[1-9]/.test(option.querySelector('.price-amount')?.textContent??'')&&/buy\s+ebook/i.test(option.querySelector('button')?.textContent??''))return result(url,'restricted','Для чтения требуется покупка электронной книги или доступ по подписке организации.',{evidence:evidence(url,'Access this book. '+(option.textContent??'').replace(/\s+/g,' '),'springer-ebook-purchase')});
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function classifyResponse(url:string,r:{code:number;url:string;body:string;type:string}):Check{
|
||||
const base={finalUrl:evidenceUrl(r.url),code:r.code};
|
||||
const {document}=parseHTML(/html|text\//i.test(r.type)?r.body:'');
|
||||
document.querySelectorAll('script,style,nav,footer,header,[hidden],[aria-hidden="true"],.hidden').forEach(n=>n.remove());
|
||||
const title=(document.querySelector('title')?.textContent??'').trim();
|
||||
const text=(document.querySelector('main,[role="main"]')??document.querySelector('body')??document.documentElement)?.textContent??'';
|
||||
const message=accessMessage(r.url,text);if(message?.status==='broken')return {...message,url,...base};
|
||||
if(r.code===404||r.code===410)return result(url,'broken',r.code===404?'Страница материала не найдена.':'Материал удалён.',base);
|
||||
if(r.code===401||/(?:\/|\.)(?:login|signin|sign-in|sso|auth)(?:[/.?]|$)/i.test(r.url))return result(url,'auth','Для чтения материала требуется авторизация.',base);
|
||||
if(r.code===403&&message)return {...message,url,...base};
|
||||
if(r.code===403||r.code===429||/captcha|verify you are human|checking your browser|just a moment|докажите, что вы|проверка браузера/i.test(title+' '+text.slice(0,2000)))return result(url,'blocked','Сайт ограничил автоматическую проверку. Доступ к полному тексту не определён.',base);
|
||||
if(r.code>=400)return result(url,'network',`Сайт временно не предоставил материал (HTTP ${r.code}).`,base);
|
||||
if(r.code<200||r.code>=300)return result(url,'unknown',`Ответ HTTP ${r.code} не подтвердил доступ к материалу.`,base);
|
||||
if(message)return {...message,url,...base};
|
||||
const purchase=purchaseOffer(r.url,r.body);if(purchase)return {...purchase,url,...base};
|
||||
if(/^(404|страница не найдена|page not found|not found)/i.test(title))return result(url,'broken','Сайт показывает страницу «не найдено».',{...base,evidence:evidence(r.url,title,'page-not-found')});
|
||||
if(/application\/pdf/i.test(r.type)&&r.body.startsWith('%PDF-'))return result(url,'unknown','PDF получен без авторизации, но не установлено, содержит ли он полный текст или только фрагмент.',base);
|
||||
return result(url,'unknown','Страница открывается, но доступ к полному тексту не подтверждён.',base);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {chromium,type Route} from 'playwright';
|
||||
import {requestPublic,safeUrl} from './http.ts';
|
||||
import {waitForHost,limitHost,HostCooldown} from './throttle.ts';
|
||||
import {accessMessage,purchaseOffer,classifyResponse,result,evidenceUrl} from './access.ts';
|
||||
import type {Check} from './model.ts';
|
||||
|
||||
import {provider} from './provider.ts';
|
||||
export function browserRequestAllowed(raw:string,root:string,method:string,resource:string){
|
||||
const url=safeUrl(raw);if(!url)return false;const u=new URL(url);
|
||||
return u.protocol==='https:'&&provider(u.href)===root&&['GET','HEAD','OPTIONS'].includes(method)&&!['image','media','font','manifest'].includes(resource);
|
||||
}
|
||||
export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise<Check>{
|
||||
const root=provider(url);if(!root)return result(url,'unknown','Для этого сайта нет правил проверки полного текста.');
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
|
||||
const abort=()=>controller.abort();parentSignal?.addEventListener('abort',abort,{once:true});if(parentSignal?.aborted)controller.abort();
|
||||
let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined;
|
||||
let requests=0,totalBytes=0,failed=false,navigations=0,mainCode=200,limit:Check|null=null;
|
||||
const active=new Set<Promise<void>>();
|
||||
try{
|
||||
controller.signal.throwIfAborted();browser=await chromium.launch({headless:true,chromiumSandbox:true,timeout:10000,env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:'/tmp',TMPDIR:'/tmp',LANG:'C.UTF-8'},args:[
|
||||
'--proxy-server=http://127.0.0.1:9','--proxy-bypass-list=<-loopback>','--disable-quic','--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
|
||||
]});
|
||||
controller.signal.throwIfAborted();const context=await browser.newContext({serviceWorkers:'block',acceptDownloads:false,viewport:{width:1280,height:900}});
|
||||
controller.signal.addEventListener('abort',()=>{void context.close().catch(()=>{});},{once:true});
|
||||
await context.routeWebSocket('**/*',socket=>socket.close());
|
||||
const handle=async(route:Route)=>{
|
||||
const request=route.request(),target=request.url(),navigation=request.isNavigationRequest();
|
||||
try{
|
||||
if(controller.signal.aborted||limit||!browserRequestAllowed(target,root,request.method(),request.resourceType())){await route.abort();return;}
|
||||
if(++requests>100||totalBytes>32000000||active.size>=10)throw new Error('Превышен лимит ресурсов страницы');
|
||||
if(navigation){if(++navigations>8)throw new Error('Слишком много переходов');await waitForHost(new URL(target).hostname,controller.signal);}
|
||||
const headers:Record<string,string>={};for(const key of ['accept','accept-language','origin','referer','cookie','content-type']){const value=request.headers()[key];if(value)headers[key]=value;}
|
||||
const response=await requestPublic(target,{signal:controller.signal,maxBytes:8000000,method:request.method() as 'GET'|'HEAD'|'OPTIONS',headers});
|
||||
totalBytes+=response.body.length;if(totalBytes>32000000)throw new Error('Превышен лимит ресурсов страницы');
|
||||
if([429,503].includes(response.code)){
|
||||
await limitHost(new URL(target).hostname,response.code,response.headers['retry-after']);
|
||||
limit=result(url,'blocked',`Сайт остановил автоматическую проверку (HTTP ${response.code}). Доступ к полному тексту не определён.`);
|
||||
}
|
||||
if(navigation&&request.frame()===page.mainFrame())mainCode=response.code;
|
||||
const fulfilled:Record<string,string>={};
|
||||
for(const [key,value] of Object.entries(response.headers))if(value!==undefined&&!['content-encoding','content-length','transfer-encoding','connection'].includes(key))fulfilled[key]=Array.isArray(value)?value.join('\n'):String(value);
|
||||
await route.fulfill({status:response.code,headers:fulfilled,body:response.body});
|
||||
}catch(error){
|
||||
if(error instanceof HostCooldown)limit=result(url,'blocked',error.message);
|
||||
else if(navigation||['script','xhr','fetch','stylesheet'].includes(request.resourceType()))failed=true;
|
||||
await route.abort().catch(()=>{});
|
||||
}
|
||||
};
|
||||
// Every request is fulfilled by the pinned Node transport. The dead proxy also prevents direct browser egress.
|
||||
await context.route('**/*',route=>{const task=handle(route);active.add(task);void task.finally(()=>active.delete(task));return task;});
|
||||
const page=await context.newPage();
|
||||
context.on('page',other=>{if(other!==page)void other.close().catch(()=>{});});
|
||||
page.setDefaultTimeout(5000);page.setDefaultNavigationTimeout(20000);
|
||||
const capture=async()=>{
|
||||
await page.locator('body').waitFor({state:'attached'});
|
||||
// Only rendered text is evidence; hidden login templates and site chrome cannot decide book access.
|
||||
const text=await page.locator('body').innerText();
|
||||
const offers=await page.locator('.book-offers__item,.app-article-access').evaluateAll(nodes=>nodes.filter(node=>{const style=getComputedStyle(node);return style.display!=='none'&&style.visibility!=='hidden'&&node.getClientRects().length>0;}).map(node=>node.outerHTML).join(''));
|
||||
return {text,offers,url:page.url(),title:await page.title()};
|
||||
};
|
||||
const settle=async()=>{
|
||||
await page.waitForFunction(()=>{
|
||||
const text=document.body?.innerText??'';
|
||||
return /чтение книги недоступно|необходимо авторизоваться|публикация изъята|чтобы получить полный доступ|книга недоступна для чтения|для продолжения работы с сайтом|библиографическое описание|not found|captcha|проверка браузера/i.test(text)||!!document.querySelector('a[href*="/course-viewer/"],a[href*="/viewer/"],a[href*="reader.new.book.ru"],.book-offers__item,.app-article-access');
|
||||
},undefined,{timeout:12000}).catch(()=>{});
|
||||
};
|
||||
await page.goto(url,{waitUntil:'domcontentloaded'});await settle();let snapshot=await capture();
|
||||
if(root==='book.ru'){
|
||||
const preview=page.getByRole('link',{name:'Ознакомиться',exact:true});
|
||||
if(await preview.count()){
|
||||
await preview.hover({timeout:3000}).catch(()=>{});
|
||||
await page.waitForFunction(()=>/гостевом\s+режиме|доступны\s+только\s+\d+\s+страниц/i.test(document.body?.innerText??''),undefined,{timeout:3000}).catch(()=>{});snapshot=await capture();
|
||||
}
|
||||
}
|
||||
const known=accessMessage(snapshot.url,snapshot.text)??purchaseOffer(snapshot.url,snapshot.offers);
|
||||
if(known)return {...known,url,code:mainCode,finalUrl:evidenceUrl(snapshot.url)};
|
||||
if(root==='urait.ru'||root==='ibooks.ru'){
|
||||
// Follow only a book reader link already present on this exact card, never subscription/purchase controls.
|
||||
const href=root==='urait.ru'?await page.locator('a[href*="/course-viewer/"],a[href*="/viewer/"]').first().getAttribute('href').catch(()=>null):snapshot.text.match(/https:\/\/ibooks\.ru\/bookshelf\/\d+\/reading/)?.[0]??null;
|
||||
const reader=href?safeUrl(href,snapshot.url):null;
|
||||
if(reader&&provider(reader)===root){await page.goto(reader,{waitUntil:'domcontentloaded'});await settle();snapshot=await capture();
|
||||
const message=accessMessage(snapshot.url,snapshot.text);if(message)return {...message,url,code:mainCode,finalUrl:evidenceUrl(snapshot.url)};
|
||||
}
|
||||
}
|
||||
if(limit)return limit;
|
||||
const escape=(text:string)=>text.replaceAll('&','&').replaceAll('<','<');
|
||||
const classified=classifyResponse(url,{code:mainCode,url:snapshot.url,body:'<html><title>'+escape(snapshot.title)+'</title><body>'+escape(snapshot.text)+'</body></html>',type:'text/html'});
|
||||
if(mainCode===403&&classified.status==='blocked')await limitHost(new URL(snapshot.url).hostname,403);
|
||||
if(failed&&classified.status==='unknown')classified.reason='Страница загрузилась не полностью. Доступ к полному тексту не подтверждён.';
|
||||
return classified;
|
||||
}catch(error){
|
||||
if(limit)return limit;
|
||||
return result(url,'unknown',controller.signal.aborted?'Не удалось определить доступ к полному тексту за отведённое время.':'Не удалось завершить браузерную проверку. Доступ к полному тексту не подтверждён.');
|
||||
}finally{clearTimeout(timer);parentSignal?.removeEventListener('abort',abort);controller.abort();await browser?.close().catch(()=>{});await Promise.allSettled(active);}
|
||||
}
|
||||
+12
-81
@@ -1,15 +1,9 @@
|
||||
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';
|
||||
import {waitForHost,limitHost,HostCooldown} from './throttle.ts';
|
||||
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;}}
|
||||
import {HostCooldown,limitHost} from './throttle.ts';
|
||||
import {safeUrl,retrieve} from './http.ts';
|
||||
import {classifyResponse,result} from './access.ts';
|
||||
import {provider} from './provider.ts';
|
||||
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){
|
||||
@@ -31,76 +25,13 @@ for(const raw of urls){const normalized=safeUrl(raw,url);if(normalized&&normaliz
|
||||
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('Адрес не разрешён политикой безопасности');
|
||||
await waitForHost(new URL(url).hostname);
|
||||
// Four redirects can add up to eight seconds of intentional host pacing.
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
|
||||
const aborted=new Promise<never>((_,reject)=>controller.signal.addEventListener('abort',()=>reject(new DOMException('Истекло время ожидания','AbortError')),{once:true}));
|
||||
export {safeUrl,publicIp,resolvePublicHost,retrieve} from './http.ts';
|
||||
export {classifyResponse as classify} from './access.ts';
|
||||
export async function checkLink(url:string):Promise<Check>{
|
||||
try{
|
||||
for(let hop=0;hop<5;hop++){
|
||||
const target=new URL(url);
|
||||
if(hop)await waitForHost(target.hostname,controller.signal);
|
||||
const 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;retryAfter?: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([403,429,503].includes(code)){response.destroy();resolve({code,body:'',type,retryAfter:response.headers['retry-after']});return;}
|
||||
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();
|
||||
});
|
||||
await limitHost(target.hostname,result.code,result.retryAfter);
|
||||
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);}
|
||||
const signal=AbortSignal.timeout(60000),page=await retrieve(url,false,signal),check=classifyResponse(url,page);
|
||||
if(page.code===403&&check.status==='blocked')await limitHost(new URL(page.url).hostname,403);
|
||||
if(check.status==='unknown'&&provider(url))return await (await import('./browser.ts')).checkBrowser(url,signal);
|
||||
return check;
|
||||
}catch(e){const error=e as Error;return result(url,error instanceof HostCooldown?'blocked':error.name==='AbortError'?'timeout':'network',error instanceof HostCooldown?error.message:'Не удалось загрузить материал. Доступ к полному тексту не подтверждён.');}
|
||||
}
|
||||
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<Check>{try{return classify(url,await retrieve(url));}catch(e){const error=e as Error;return {url,status:error instanceof HostCooldown?'blocked':error.name==='AbortError'?'timeout':'network',reason:error.name==='AbortError'?'Превышен общий лимит ожидания: 30 секунд, включая паузы между перенаправлениями.':`Не удалось загрузить: ${error.message.slice(0,180)}`,checkedAt:new Date().toISOString()};}}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import {lookup} from 'node:dns/promises';
|
||||
import {request as httpRequest,type IncomingHttpHeaders} from 'node:http';
|
||||
import {request as httpsRequest} from 'node:https';
|
||||
import {createUnzip,createBrotliDecompress} from 'node:zlib';
|
||||
import {waitForHost,limitHost} from './throttle.ts';
|
||||
|
||||
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 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 type PublicResponse={code:number;headers:IncomingHttpHeaders;body:Buffer;type:string};
|
||||
// One pinned socket, no implicit redirects/proxies; browser redirects pass this boundary again.
|
||||
export async function requestPublic(raw:string,options:{signal?:AbortSignal;maxBytes?:number;headers?:Record<string,string>;method?:'GET'|'HEAD'|'OPTIONS'}={}):Promise<PublicResponse>{
|
||||
const url=safeUrl(raw);if(!url)throw new Error('Адрес не разрешён политикой безопасности');
|
||||
const signal=AbortSignal.any([AbortSignal.timeout(12000),...(options.signal?[options.signal]:[])]);
|
||||
const aborted=new Promise<never>((_,reject)=>{if(signal.aborted)reject(new DOMException('Истекло время ожидания','AbortError'));else signal.addEventListener('abort',()=>reject(new DOMException('Истекло время ожидания','AbortError')),{once:true});});
|
||||
const target=new URL(url),ip=await Promise.race([resolvePublicHost(target.hostname),aborted]);
|
||||
return new Promise((resolve,reject)=>{
|
||||
const request=(target.protocol==='https:'?httpsRequest:httpRequest)(target,{
|
||||
agent:false,family:ip.family,signal,lookup:(_hostname,_options,callback)=>callback(null,ip.address,ip.family),method:options.method??'GET',
|
||||
headers:{'User-Agent':'Sverka/1.3 (academic bibliography access check)','Accept':'text/html,application/pdf;q=0.9,*/*;q=0.8','Accept-Encoding':'identity',...options.headers},
|
||||
},response=>{
|
||||
const code=response.statusCode??0,headers=response.headers,type=String(headers['content-type']??'');
|
||||
if(code>=300&&code<400||[429,503].includes(code)||options.method==='HEAD'){response.destroy();resolve({code,headers,type,body:Buffer.alloc(0)});return;}
|
||||
void(async()=>{
|
||||
const encoding=String(headers['content-encoding']??''),stream=/gzip|deflate/i.test(encoding)?response.pipe(createUnzip()):/br/i.test(encoding)?response.pipe(createBrotliDecompress()):response;
|
||||
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>(options.maxBytes??3000000))throw new Error('Превышен допустимый размер загрузки');chunks.push(bytes);}resolve({code,headers,type,body:Buffer.concat(chunks)});}
|
||||
catch(error){response.destroy();stream.destroy();reject(error);}
|
||||
})();
|
||||
});request.once('error',reject);request.end();
|
||||
});
|
||||
}
|
||||
export function decodeBody(body:Buffer,type:string){try{return new TextDecoder(/charset=([\w-]+)/i.exec(type)?.[1]??'utf-8').decode(body);}catch{return body.toString('utf8');}}
|
||||
export async function retrieve(raw:string,source=false,parentSignal?:AbortSignal){
|
||||
let url=safeUrl(raw);if(!url)throw new Error('Адрес не разрешён политикой безопасности');
|
||||
const signal=AbortSignal.any([AbortSignal.timeout(30000),...(parentSignal?[parentSignal]:[])]);
|
||||
for(let hop=0;hop<5;hop++){
|
||||
await waitForHost(new URL(url).hostname,signal);
|
||||
const result=await requestPublic(url,{signal,maxBytes:source?3000000:1500000});
|
||||
if(result.code!==403||source)await limitHost(new URL(url).hostname,result.code,result.headers['retry-after']);
|
||||
const location=result.headers.location;
|
||||
if(location&&result.code>=300&&result.code<400){const next=safeUrl(location,url);if(!next)throw new Error('Небезопасное перенаправление');url=next;continue;}
|
||||
return {code:result.code,url,type:result.type,body:decodeBody(result.body,result.type)};
|
||||
}
|
||||
throw new Error('Слишком много перенаправлений');
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
export const VERSION='1.2.2';
|
||||
export const VERSION='1.3.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'},
|
||||
@@ -6,8 +6,8 @@ export const PROGRAMS=[
|
||||
{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<string,string>={ok:'Доступна',broken:'Не найдена',auth:'Требуется вход',blocked:'Доступ запрещён',timeout:'Нет ответа',network:'Ошибка соединения',restricted:'Ограничен доступ',unknown:'Нужна проверка'};
|
||||
export type Check={url:string;finalUrl?:string;status:Status;reason:string;code?:number;checkedAt:string};
|
||||
export const LABELS:Record<string,string>={ok:'Полный текст доступен',broken:'Недоступна: удалена',auth:'Недоступна: нужен вход',blocked:'Проверка ограничена',timeout:'Нет ответа',network:'Ошибка соединения',restricted:'Недоступна',unknown:'Доступ не подтверждён'};
|
||||
export type Check={url:string;finalUrl?:string;status:Status;reason:string;code?:number;checkedAt:string;rulesVersion?:string;profile?:'anonymous';evidence?:{text:string;url:string;rule: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};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function provider(raw:string){try{const h=new URL(raw).hostname;if(h==='springernature.com'||h.endsWith('.springernature.com'))return 'springer.com';return ['lanbook.com','urait.ru','znanium.ru','ibooks.ru','springer.com','book.ru'].find(root=>h===root||h.endsWith('.'+root))??null;}catch{return null;}}
|
||||
+6
-2
@@ -1,10 +1,11 @@
|
||||
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';
|
||||
import {checkCurrent} from './access.ts';
|
||||
async function read<T>(key:string):Promise<T|null>{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<Run|null>{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<string,Check>={};
|
||||
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};}
|
||||
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:')){const check:Check=JSON.parse(row.value);checks[row.key.slice(5)]=checkCurrent(check)?check:{...check,status:'unknown',reason:'Результат устарел или получен по прежним правилам. Запустите проверку доступа к полному тексту.',evidence:undefined};}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();
|
||||
@@ -25,7 +26,10 @@ try{const page=await retrieve(task.url,true);if(page.code!==200)throw new Error(
|
||||
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);}
|
||||
}
|
||||
const keys=course.refs.map(ref=>'link:'+ref.url),{rows}=await db.query<{key:string;value:string}>('SELECT key,value FROM records WHERE key=ANY($1::text[])',[keys]);
|
||||
const checks=new Map(rows.map(row=>[row.key,JSON.parse(row.value) as Check]));
|
||||
for(const ref of course.refs)if((run.force||!checkCurrent(checks.get('link:'+ref.url)))&&!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;}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
basePath: '/courses',
|
||||
serverExternalPackages: ['linkedom','pg'],
|
||||
serverExternalPackages: ['linkedom','pg','playwright','playwright-core'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sverka",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
@@ -9,23 +9,25 @@
|
||||
"dev": "node server/runtime.mjs --dev",
|
||||
"build": "next build --webpack",
|
||||
"start": "node server/runtime.mjs",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||
"test": "node --experimental-strip-types --experimental-test-module-mocks --test tests/*.test.ts",
|
||||
"test:browser": "node --experimental-strip-types --experimental-test-module-mocks --test tests/browser.security.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"
|
||||
"next": "16.3.4",
|
||||
"pg": "8.16.3",
|
||||
"playwright": "1.64.1",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3",
|
||||
"@types/pg": "8.15.5"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
|
||||
Generated
+19
@@ -20,6 +20,9 @@ importers:
|
||||
pg:
|
||||
specifier: 8.16.3
|
||||
version: 8.16.3
|
||||
playwright:
|
||||
specifier: 1.64.1
|
||||
version: 1.64.1
|
||||
react:
|
||||
specifier: 19.2.6
|
||||
version: 19.2.6
|
||||
@@ -581,6 +584,16 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
playwright-core@1.64.1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.64.1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.23:
|
||||
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1191,6 +1204,12 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
playwright-core@1.64.1: {}
|
||||
|
||||
playwright@1.64.1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.1
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {accessMessage,classifyResponse,purchaseOffer,checkCurrent,result,evidenceUrl,RULES_VERSION} from '../lib/access.ts';
|
||||
const lan='https://e.lanbook.com/book/111431',urait='https://urait.ru/course-viewer/6BDE5BCD-49B3-4DCD-B6BF-F91BF5F4657E';
|
||||
const html=(body:string)=>'<html><title>Book</title><body><main>'+body+'</main></body></html>';
|
||||
const classify=(body:string,url=lan)=>classifyResponse(url,{url,body:html(body),code:200,type:'text/html'});
|
||||
const offer='<div class="book-offers__item">Доступ онлайн<div class="book-offers__price">от 156 ₽</div><span class="book-offers__ctrl" data-id="372956">В корзину</span></div>';
|
||||
|
||||
test('Lanbook denial is unavailable with an explicit login reason and evidence',()=>{
|
||||
const check=classify('<div class="lan-callout lan-callout-danger"><h4>Чтение книги недоступно</h4><p>Для доступа к чтению книги необходимо авторизоваться.</p></div>');
|
||||
assert.equal(check.status,'auth');assert.equal(check.reason,'Для чтения книги требуется авторизация.');assert.match(check.evidence!.text,/необходимо авторизоваться/);assert.equal(check.profile,'anonymous');
|
||||
});
|
||||
test('Urait requires both login and subscription; PDF prohibition alone is insufficient',()=>{
|
||||
const check=accessMessage(urait,'Чтобы получить полный доступ к курсам, авторизуйтесь на аккаунте с подпиской учебного заведения или личной подпиской.')!;
|
||||
assert.equal(check.status,'restricted');assert.equal(check.reason,'Для чтения книги требуются авторизация и подписка.');
|
||||
assert.equal(accessMessage(urait,'Оплаченный доступ к контенту предоставляется только на платформе. Скачивание контента в PDF не доступно.'),null);
|
||||
});
|
||||
test('Znanium withdrawn and priced online access are different evidence',()=>{
|
||||
assert.equal(classify('Данная публикация изъята из фонда.','https://znanium.ru/catalog/document?pid=1040849').reason,'Публикация изъята из фонда.');
|
||||
const paid=purchaseOffer('https://znanium.ru/catalog/document?id=372956',offer)!;assert.equal(paid.status,'restricted');assert.match(paid.reason,/покупка онлайн-доступа/);assert.match(paid.evidence!.url,/id=372956/);
|
||||
assert.equal(purchaseOffer('https://znanium.ru/catalog/document?id=372956',offer.replace('Доступ онлайн','Печатная книга')),null);
|
||||
assert.equal(purchaseOffer('https://znanium.ru/catalog/document?id=999',offer),null);
|
||||
assert.equal(purchaseOffer('https://znanium.ru/catalog/document?id=372956',offer.replace('156','0')),null);
|
||||
});
|
||||
test('iBooks uses the reader denial rather than a login button on the catalogue card',()=>{
|
||||
const check=accessMessage('https://ibooks.ru/bookshelf/356956/reading','Для продолжения работы с сайтом, пожалуйста, войдите в систему')!;
|
||||
assert.equal(check.status,'auth');assert.equal(classify('<button>Войти</button><h1>Linux API</h1>Аннотация','https://ibooks.ru/products/356956').status,'unknown');
|
||||
const url='https://ibooks.ru/bookshelf/356956/reading';assert.equal(classifyResponse(url,{url,body:html('Для продолжения работы с сайтом, пожалуйста, войдите в систему'),code:403,type:'text/html'}).status,'auth');
|
||||
});
|
||||
test('Springer requires an eBook purchase offer, not the paper edition price',()=>{
|
||||
const url='https://link.springer.com/book/10.1007/978-3-642-17545-9',block='<div class="app-article-access"><h2>Access this book</h2><div class="buying-option"><span>eBook</span><span class="price-amount">EUR 117.69</span><button>Buy eBook</button></div></div>';
|
||||
assert.equal(purchaseOffer(url,block)?.status,'restricted');
|
||||
assert.equal(purchaseOffer(url,block.replaceAll('eBook','Hardcover Book')),null);
|
||||
assert.equal(purchaseOffer(url,block.replace('Access this book','This is an open access book')),null);
|
||||
});
|
||||
test('hidden templates, general login, annotations and successful HTTP do not confirm access',()=>{
|
||||
for(const body of ['<h1>Book</h1>Аннотация','<header><input type="password"/>Для чтения авторизуйтесь</header><p>Book card</p>','<div hidden>Для чтения необходимо авторизоваться</div>','<div class="hidden">Для чтения необходимо авторизоваться</div>'])assert.equal(classify(body).status,'unknown');
|
||||
});
|
||||
test('old rule versions and expired checks are invalidated independently of course content',()=>{
|
||||
const now=Date.now(),fresh=result(lan,'auth','Login');assert.equal(checkCurrent(fresh,now),true);
|
||||
assert.equal(checkCurrent({...fresh,rulesVersion:undefined},now),false);assert.equal(checkCurrent({...fresh,rulesVersion:'old'},now),false);
|
||||
assert.equal(checkCurrent({...fresh,checkedAt:new Date(now-86400001).toISOString()},now),false);
|
||||
assert.equal(checkCurrent({...fresh,status:'unknown',checkedAt:new Date(now-600001).toISOString()},now),false);assert.equal(fresh.rulesVersion,RULES_VERSION);
|
||||
});
|
||||
test('evidence URLs retain book identifiers while discarding tokens and tracking',()=>assert.equal(evidenceUrl('https://znanium.ru/catalog/document?id=372956&token=secret&utm_source=x#foo'),'https://znanium.ru/catalog/document?id=372956'));
|
||||
test('BOOK.ru guest preview is unavailable even when twenty pages can be read',()=>{
|
||||
const check=accessMessage('https://book.ru/book/940586','Перейти в модуль чтения в гостевом режиме, где доступны только 20 страниц книги.')!;
|
||||
assert.equal(check.status,'restricted');assert.match(check.reason,/до 20 страниц/);assert.match(check.reason,/полный текст недоступен/);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {browserRequestAllowed} from '../lib/browser.ts';
|
||||
test('browser policy blocks other providers, internal targets, writes and heavy resources',()=>{
|
||||
for(const url of ['http://127.0.0.1/','https://127.0.0.1/','https://[::1]/','http://e.lanbook.com/','https://e.lanbook.com.evil.org/','https://urait.ru/','file:///etc/passwd','https://user:pass@e.lanbook.com/'])assert.equal(browserRequestAllowed(url,'lanbook.com','GET','document'),false,url);
|
||||
assert.equal(browserRequestAllowed('https://e.lanbook.com/book/111431','lanbook.com','POST','fetch'),false);
|
||||
assert.equal(browserRequestAllowed('https://e.lanbook.com/cover.jpg','lanbook.com','GET','image'),false);
|
||||
assert.equal(browserRequestAllowed('https://e.lanbook.com/main.js','lanbook.com','GET','script'),true);
|
||||
assert.equal(browserRequestAllowed('https://link.springernature.com/book/a','springer.com','GET','document'),true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test,mock} from 'node:test';
|
||||
import {createServer} from 'node:http';
|
||||
import {createSocket} from 'node:dgram';
|
||||
import {useTestDatabase} from './test-database.ts';
|
||||
useTestDatabase('browser');
|
||||
const original=await import('../lib/http.ts');
|
||||
let fixture='',proxied:string[]=[];
|
||||
mock.module(new URL('../lib/http.ts',import.meta.url).href,{namedExports:{...original,requestPublic:async(url:string)=>{
|
||||
proxied.push(url);
|
||||
if(url.endsWith('/redirect'))return {code:302,headers:{location:target},body:Buffer.alloc(0),type:'text/html'};
|
||||
if(!url.startsWith('https://e.lanbook.com/'))throw new Error('Unexpected transport destination');
|
||||
return {code:200,headers:{'content-type':'text/html; charset=utf-8'},body:Buffer.from(fixture),type:'text/html'};
|
||||
}}});
|
||||
const {checkBrowser}=await import('../lib/browser.ts');
|
||||
let target='';
|
||||
test('sandboxed Chromium blocks private HTTP, redirects, frames, popups, workers, WebSockets and WebRTC UDP',async()=>{
|
||||
let httpHits=0,udpHits=0;
|
||||
const server=createServer((_req,res)=>{httpHits++;res.end('private');}),udp=createSocket('udp4');udp.on('message',()=>udpHits++);
|
||||
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));await new Promise<void>(resolve=>udp.bind(0,'127.0.0.1',resolve));
|
||||
const address=server.address();assert.ok(address&&typeof address!=='string');target='http://127.0.0.1:'+address.port+'/private';
|
||||
const udpAddress=udp.address();
|
||||
fixture=`<!doctype html><html><body><h1>Book</h1><div id="message">Loading</div><script>
|
||||
fetch(${JSON.stringify(target)}).catch(()=>{});
|
||||
fetch('https://e.lanbook.com/redirect').catch(()=>{});
|
||||
const iframe=document.createElement('iframe');iframe.src=${JSON.stringify(target)};document.body.appendChild(iframe);
|
||||
window.open(${JSON.stringify(target)});
|
||||
new Worker(URL.createObjectURL(new Blob(['fetch('+JSON.stringify(${JSON.stringify(target)})+').catch(()=>{})'])));
|
||||
try{new WebSocket(${JSON.stringify(target.replace('http:','ws:'))})}catch{}
|
||||
try{const rtc=new RTCPeerConnection({iceServers:[{urls:'stun:127.0.0.1:${udpAddress.port}'}]});rtc.createDataChannel('test');rtc.createOffer().then(offer=>rtc.setLocalDescription(offer));}catch{}
|
||||
setTimeout(()=>document.getElementById('message').textContent='Для доступа к чтению книги необходимо авторизоваться.',2000);
|
||||
</script></body></html>`;
|
||||
try{
|
||||
const check=await checkBrowser('https://e.lanbook.com/book/111431');
|
||||
assert.equal(check.status,'auth',check.reason);assert.equal(httpHits,0);assert.equal(udpHits,0);
|
||||
assert.ok(proxied.every(url=>url.startsWith('https://e.lanbook.com/')));assert.ok(proxied.includes('https://e.lanbook.com/redirect'));
|
||||
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()));udp.close();}
|
||||
});
|
||||
@@ -8,12 +8,12 @@ const html='<html><head><title>Book</title></head><body><h1>Algorithms</h1><div
|
||||
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('<h1>Course</h1>',url).literature,false));
|
||||
test('pagination follows current program and excludes archived years',()=>{const root='https://www.hse.ru/ba/is/courses',p=parseCatalog('<a href="/ba/is/courses/1.html">A</a><a href="/ba/is/courses/page2.html?year=2026">2</a><a href="/ba/is/courses?year=2023">archive</a><a href="/ba/it/courses/4.html">other</a>',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('<html><title>Just a moment</title><body>Checking your browser</body></html>')).status,'blocked');assert.equal(classify(url,result('<html><body>Sign in to read this book</body></html>')).status,'restricted');assert.equal(classify(url,result('<html><body><input type="password"/></body></html>')).status,'auth');assert.equal(classify(url,result('<html><body>Open book</body></html>')).status,'ok');});
|
||||
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('<html><title>Just a moment</title><body>Checking your browser</body></html>')).status,'blocked');assert.equal(classify(url,result('<html><body>Sign in to read this book</body></html>')).status,'auth');assert.equal(classify(url,result('<html><body><input type="password"/></body></html>')).status,'unknown');assert.equal(classify(url,result('<html><body>Open book</body></html>')).status,'unknown');});
|
||||
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'));});
|
||||
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('<html><body><div>Загрузка...</div><script src="app.js"></script></body></html>')).status,'unknown');});
|
||||
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('<html><body><div>Загрузка...</div><script src="app.js"></script></body></html>')).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');});
|
||||
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,'unknown');assert.equal(classify(url,{...result('%PDF-1.4 book file\n%%EOF'),type:'application/pdf'}).status,'unknown');});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test,mock} from 'node:test';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {Readable} from 'node:stream';
|
||||
let connections=0,pinned='',code=200;
|
||||
mock.module('node:dns/promises',{namedExports:{lookup:async(host:string)=>[{address:host.startsWith('private')?'127.0.0.1':'1.1.1.1',family:4}]}});
|
||||
mock.module('node:https',{namedExports:{request:(_url:URL,options:{lookup:(h:string,o:object,cb:(e:unknown,ip:string)=>void)=>void},callback:(r:unknown)=>void)=>{
|
||||
connections++;options.lookup('unused',{},(_e,ip)=>{pinned=ip;});
|
||||
const request=new EventEmitter() as EventEmitter&{end:()=>void};request.end=()=>queueMicrotask(()=>{const response=Readable.from(['<html>Book</html>']) as Readable&{statusCode:number;headers:object};response.statusCode=code;response.headers={'content-type':'text/html',location:code===302?'http://127.0.0.1/private':undefined};callback(response);});return request;
|
||||
}}});
|
||||
const {requestPublic}=await import('../lib/http.ts');
|
||||
test('outbound transport rejects unsafe URLs and private DNS before creating a socket',async()=>{
|
||||
for(const url of ['http://127.0.0.1/','http://[::1]/','file:///etc/passwd','https://private.provider.org/'])await assert.rejects(requestPublic(url));
|
||||
assert.equal(connections,0);
|
||||
});
|
||||
test('a public DNS address is pinned into the socket lookup',async()=>{
|
||||
const response=await requestPublic('https://public.provider.org/book');assert.equal(pinned,'1.1.1.1');assert.equal(response.code,200);assert.equal(connections,1);
|
||||
});
|
||||
test('a redirect is returned to the caller without following it past the trust boundary',async()=>{
|
||||
code=302;const before=connections,response=await requestPublic('https://public.provider.org/redirect');assert.equal(response.code,302);assert.equal(response.headers.location,'http://127.0.0.1/private');assert.equal(connections,before+1);
|
||||
});
|
||||
+10
-1
@@ -3,6 +3,7 @@ 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 {result} from '../lib/access.ts';
|
||||
import {useTestDatabase} from './test-database.ts';
|
||||
useTestDatabase('scanner');
|
||||
test('only one shared manual run can be started',async()=>{
|
||||
@@ -22,10 +23,18 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
test('a step consumes only one bibliography link',async()=>{
|
||||
const db=await database(),run=(await getRun())!;run.phase='links';run.links=['http://127.0.0.1/a','http://127.0.0.1/b'];run.checked=0;
|
||||
await db.query("UPDATE scan SET data=$1,lease=0,token='' WHERE id=1",[JSON.stringify(run)]);
|
||||
const after=(await step())!;assert.equal(after.checked,1);assert.deepEqual(after.links,['http://127.0.0.1/b']);assert.equal(after.status,'running');
|
||||
});
|
||||
|
||||
test('previous HTTP-only results are not presented as available after a rules upgrade',async()=>{
|
||||
const db=await database(),old={url:'https://books.org/old',status:'ok',reason:'HTTP 200',checkedAt:new Date().toISOString()};
|
||||
await db.query('INSERT INTO records(key,value) VALUES($1,$2)', ['link:'+old.url,JSON.stringify(old)]);
|
||||
assert.equal((await dashboard()).checks[old.url].status,'unknown');
|
||||
const current=result(old.url,'auth','Для чтения книги требуется авторизация.');await db.query('UPDATE records SET value=$1 WHERE key=$2',[JSON.stringify(current),'link:'+old.url]);
|
||||
assert.equal((await dashboard()).checks[old.url].status,'auth');
|
||||
});
|
||||
Reference in new issue
Block a user