Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4745389e | ||
|
|
4eea3ae3af | ||
|
|
69583fb9fe | ||
|
|
b57b7060ec | ||
|
|
3f27a0a292 | ||
|
|
b30d909a23 |
@@ -25,7 +25,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
||||
- `markdown.py` — markdown-it-py renderer.
|
||||
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
|
||||
- `seed.py` — demo content, written only on first database creation.
|
||||
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
||||
- `analytics.py` — visit analytics collection (see `docs/analytics.md`). UA formatting/bot detection comes from the **uarite** package.
|
||||
- `frontend/src/` — Vue editor and public-page JS entries.
|
||||
- `main.js` — Vue editor app entry.
|
||||
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
||||
|
||||
+22
-5
@@ -63,20 +63,35 @@ Each `Client` record (shared by every event, keyed by hash):
|
||||
when a database is available,
|
||||
- `city` — city name from the DB-IP MMDB lookup, when available,
|
||||
- `ua` — raw `User-Agent` string,
|
||||
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
||||
parsable, otherwise the raw string,
|
||||
- `hide` — true for admin clients (`hide` message field): everything this
|
||||
client ever did is recorded but excluded from every statistic and from the
|
||||
viewer payload. This is the one flag set at record time — it is a client
|
||||
property, not a classification.
|
||||
|
||||
The viewer payload adds one display-time field to each client, never
|
||||
persisted (stored records keep the default and old data always follows the
|
||||
current uarite version):
|
||||
|
||||
- `uarite` — the `uarite.UA` dataclass from parsing the raw UA
|
||||
(`pretty`/`engine`/`os`/`provider`/`kind`/`url`): the crawler name for
|
||||
bots,
|
||||
with a category suffix only where a provider runs crawlers of more than
|
||||
one kind (`GPTBot (AI)` vs `OAI-SearchBot (search)`, `Googlebot (search)`
|
||||
vs `Google-Extended (AI)`; single-kind providers stay plain: `Facebook`,
|
||||
`WhatsApp`), `Browser/major OS` on the desktop, the device where that is
|
||||
the relevant information (iPhone reports its iOS version, Android phones
|
||||
their model instead of the OS), otherwise the raw string; `url` is the
|
||||
crawler's info page when uarite knows one (rendered as a 🔗 link after the
|
||||
pretty UA in the viewer), `kind` drives the bot classification.
|
||||
|
||||
A reverse-DNS lookup is attempted for each new client and the result, when
|
||||
available, is stored as `host`; local/reserved/multicast addresses are
|
||||
skipped. If a DB-IP MMDB file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present
|
||||
in the working directory, it is loaded at startup and used to look up
|
||||
`country`/`city`. These lookups run in background tasks after the event is
|
||||
stored, so WebSocket message handling is never delayed. The decompressed
|
||||
`dbip-*.mmdb` file is kept in the working directory and ignored by git. The
|
||||
stored, so WebSocket message handling is never delayed. Only the downloaded
|
||||
`.mmdb.gz` is kept on disk (in the working directory, ignored by git); it is
|
||||
decompressed into RAM when opened. The
|
||||
CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest
|
||||
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP at startup (in the app lifespan,
|
||||
before the MMDB is opened), skipping the download when the local database is
|
||||
@@ -168,7 +183,9 @@ for misses.
|
||||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||||
documents never register as visits. JS-running crawlers (Googlebot,
|
||||
GoogleOther, Applebot, ...) do connect and send messages, but their UA
|
||||
gives them away (`_is_bot_ua`): their messages are ignored at display
|
||||
gives them away (`_is_bot_ua`, backed by `uarite.uaparse` — which
|
||||
also knows the disguised ones: facebookexternalhit, Google-Extended,
|
||||
WhatsApp, ...): their messages are ignored at display
|
||||
time, so their GETs never match and land in the crawler list too. Real-
|
||||
browser bots whose UA does not match are caught by engagement: a visit
|
||||
whose total reported reading time is under 5 seconds (`_MIN_VISIT_READ`;
|
||||
|
||||
@@ -146,7 +146,11 @@ served Markdown at render time.
|
||||
|
||||
`chunk_markdown(markdown)` splits the source into block-level chunks —
|
||||
blank-line-separated blocks: headings, paragraphs, code fences (kept whole),
|
||||
list blocks, tables, HTML blocks. A chunk's identity is its **source text**,
|
||||
list blocks, tables, HTML blocks. Container fence lines (`::: name` openers
|
||||
and `:::` closers) are always their own chunk, blank lines or not — folded
|
||||
into a prose chunk the closer would cross to the translator as part of the
|
||||
text, where the model can drop it (the rest of the page then renders inside
|
||||
the container). A chunk's identity is its **source text**,
|
||||
gettext-msgid style:
|
||||
|
||||
```python
|
||||
|
||||
@@ -218,6 +218,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
:ip-display="v.ipDisplay"
|
||||
:ua="v.ua"
|
||||
:ua-raw="v.uaRaw"
|
||||
:ua-url="v.uaUrl"
|
||||
:country="v.country"
|
||||
:city="v.city"
|
||||
:lang="v.lang"
|
||||
@@ -253,6 +254,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
:ip-display="c.ipDisplay"
|
||||
:ua="c.ua"
|
||||
:ua-raw="c.uaRaw"
|
||||
:ua-url="c.uaUrl"
|
||||
:country="c.country"
|
||||
:city="c.city"
|
||||
:lang="c.lang"
|
||||
@@ -299,6 +301,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
:ip-display="a.ipDisplay"
|
||||
:ua="a.ua"
|
||||
:ua-raw="a.uaRaw"
|
||||
:ua-url="a.uaUrl"
|
||||
:ua-raws="a.uaRaws"
|
||||
:country="a.country"
|
||||
:city="a.city"
|
||||
:lang="a.lang"
|
||||
@@ -506,22 +510,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
.visit-table .clickable-list,
|
||||
.visit-table .last-seen {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.visit-table :deep(.copy-popup) {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.25rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--text, CanvasText);
|
||||
color: var(--bg, Canvas);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.crawler-top-uas {
|
||||
|
||||
@@ -403,14 +403,6 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block-head .icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The banner design selector stays compact; the upload button is pushed
|
||||
|
||||
@@ -9,23 +9,28 @@
|
||||
// store and re-renders it).
|
||||
import { computed } from 'vue'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
import { useStore } from './store'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
// The "(primary)" marker is admin-panel information; the public selector
|
||||
// lists plain languages.
|
||||
const options = computed(() =>
|
||||
store.langAlternates.map((a) => ({
|
||||
tag: a.tag,
|
||||
code: a.tag,
|
||||
name: langName(a.tag),
|
||||
flag: flagFor(a.tag),
|
||||
primary: false,
|
||||
})),
|
||||
)
|
||||
// lists plain languages. Order: the primary language first, then the rest
|
||||
// in the lang tab's geographic grouping (./langs langSort) — the head's
|
||||
// hreflang order is just alphabetical.
|
||||
const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '')
|
||||
const options = computed(() => {
|
||||
const rest = langSort(
|
||||
store.langAlternates.map((a) => a.tag).filter((t) => t !== primaryTag.value),
|
||||
)
|
||||
return [primaryTag.value, ...rest].filter(Boolean).map((tag) => ({
|
||||
tag,
|
||||
code: tag,
|
||||
name: langName(tag),
|
||||
flag: flagFor(tag),
|
||||
primary: false,
|
||||
}))
|
||||
})
|
||||
// The explicit pick, else the served language (header-autodetected pages
|
||||
// may have neither), else the primary.
|
||||
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
|
||||
|
||||
@@ -33,7 +33,7 @@ import { keymap } from '@codemirror/view'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
@@ -121,11 +121,13 @@ function normPath(p) {
|
||||
// localization settings tab).
|
||||
|
||||
// The picker's options: the primary language first, then the union of the
|
||||
// page's translations and the site-wide configured targets, sorted.
|
||||
// page's translations and the site-wide configured targets in the lang
|
||||
// tab's geographic grouping (./langs langSort).
|
||||
const langOptions = computed(() => {
|
||||
const others = [...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||
.filter((l) => l && l !== primaryLang.value)
|
||||
.sort()
|
||||
const others = langSort(
|
||||
[...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||
.filter((l) => l && l !== primaryLang.value),
|
||||
)
|
||||
return [primaryLang.value, ...others].map((code) => ({
|
||||
tag: code === primaryLang.value ? '' : code,
|
||||
code,
|
||||
|
||||
@@ -782,14 +782,6 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block-head .icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, wa
|
||||
import StructureTree from './StructureTree.vue'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { slugify } from './slugify'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import { dropPageCache, loadPlain } from './swapdoc'
|
||||
|
||||
@@ -40,9 +40,10 @@ const primaryLang = ref('en')
|
||||
const siteLangs = ref([])
|
||||
|
||||
// The strip's options: the primary language first, then the configured
|
||||
// translation targets (the lang tab manages that set).
|
||||
// translation targets (the lang tab manages that set) in the lang tab's
|
||||
// geographic grouping (./langs langSort).
|
||||
const langOptions = computed(() =>
|
||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
||||
[primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))]
|
||||
.map((code) => ({
|
||||
tag: code === primaryLang.value ? '' : code,
|
||||
code,
|
||||
@@ -63,7 +64,7 @@ watch(lang, () => refreshPages())
|
||||
// dropdown lists "inherit" first (naming what it resolves to), then every
|
||||
// site language. Setting it on a section covers its whole subtree.
|
||||
const rowLangChoices = computed(() =>
|
||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
||||
[primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))]
|
||||
.map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
|
||||
)
|
||||
function rowLangOptions(el) {
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
// Clicking the IP copies the full address to the clipboard.
|
||||
// ``variantCount`` overrides the UA line to warn when multiple client
|
||||
// fingerprints share the same IP (e.g. a scanner rotating UAs).
|
||||
// Clicking the UA line copies the raw UA(s) to the clipboard, one per line
|
||||
// (``uaRaws`` carries every variation for multi-client IPs).
|
||||
import { computed } from 'vue'
|
||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||
import { copyIp, formatLang } from './analytics/format.js'
|
||||
import { copyIp, copyList, formatLang } from './analytics/format.js'
|
||||
import { langName } from './langs.js'
|
||||
|
||||
const props = defineProps({
|
||||
ip: { type: String, default: '' },
|
||||
ipDisplay: { type: String, default: '—' },
|
||||
ua: { type: String, default: '' },
|
||||
uaRaw: { type: String, default: '' },
|
||||
uaRaws: { type: String, default: '' },
|
||||
uaUrl: { type: String, default: '' },
|
||||
country: { type: String, default: '' },
|
||||
city: { type: String, default: '' },
|
||||
lang: { type: String, default: '' },
|
||||
@@ -26,6 +31,7 @@ const hasCity = computed(() => !!(props.city && props.city !== '—'))
|
||||
const hasLocale = computed(() => hasCountry.value || hasCity.value)
|
||||
const langValue = computed(() => props.langDisplay || formatLang(props.lang))
|
||||
const showLang = computed(() => langValue.value && langValue.value !== '—')
|
||||
const uaCopy = computed(() => props.uaRaws || props.uaRaw)
|
||||
|
||||
function flagSvg(code) {
|
||||
return flagSvgs[code?.toUpperCase()] || ''
|
||||
@@ -58,10 +64,16 @@ function countryName(code) {
|
||||
</div>
|
||||
<div class="visitor-row">
|
||||
<div class="ua-line">
|
||||
<small v-if="variantCount > 1" class="muted variant-hint">{{ variantCount }} client variations</small>
|
||||
<small v-else class="muted" :title="uaRaw">{{ ua || '—' }}</small>
|
||||
<small v-if="variantCount > 1" class="muted variant-hint clickable-ip"
|
||||
:title="uaCopy"
|
||||
@click="copyList(uaCopy, $event)">{{ variantCount }} client variations</small>
|
||||
<small v-else class="muted clickable-ip" :title="uaRaw"
|
||||
@click="copyList(uaCopy, $event)">{{ ua || '—' }}</small><a v-if="uaUrl && variantCount <= 1"
|
||||
class="ua-link icon-btn" :href="uaUrl"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
@click.stop>🔗</a>
|
||||
</div>
|
||||
<div v-if="showLang && variantCount <= 1" class="locale-lang"><small class="muted">{{ langValue }}</small></div>
|
||||
<div v-if="showLang && variantCount <= 1" class="locale-lang"><small class="muted" :title="langName(lang)">{{ langValue }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -121,6 +133,13 @@ function countryName(code) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ua-link {
|
||||
text-decoration: none;
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.2em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.locale-lang {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -25,32 +25,31 @@ export const hostIP = (ip) => {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopiedFeedback(el) {
|
||||
if (!el || typeof document === 'undefined') return
|
||||
function showCopiedFeedback(el, event) {
|
||||
if (typeof document === 'undefined') return
|
||||
const popup = document.createElement('span')
|
||||
popup.textContent = 'Copied!'
|
||||
popup.className = 'copy-popup'
|
||||
// Fixed to the viewport at the click point: table cells clip absolute
|
||||
// popups with their overflow: hidden ellipsis styling.
|
||||
const x = event?.clientX ?? 0
|
||||
const y = event?.clientY ?? 0
|
||||
popup.style.cssText =
|
||||
'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' +
|
||||
'transform:translateX(-50%);padding:0.15rem 0.4rem;' +
|
||||
`position:fixed;left:${x}px;top:${y}px;` +
|
||||
'transform:translate(-50%, calc(-100% - 0.5rem));padding:0.15rem 0.4rem;' +
|
||||
'background:var(--text, CanvasText);color:var(--bg, Canvas);' +
|
||||
'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' +
|
||||
'pointer-events:none;z-index:10;'
|
||||
el.classList.add('has-copy-popup')
|
||||
el.appendChild(popup)
|
||||
setTimeout(() => {
|
||||
popup.remove()
|
||||
el.classList.remove('has-copy-popup')
|
||||
}, 1200)
|
||||
'pointer-events:none;z-index:100;'
|
||||
document.body.appendChild(popup)
|
||||
setTimeout(() => popup.remove(), 1200)
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyIp(ip, event) {
|
||||
if (!ip) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
showCopiedFeedback(el)
|
||||
showCopiedFeedback(event?.currentTarget, event)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -59,10 +58,9 @@ export async function copyIp(ip, event) {
|
||||
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyList(text, event) {
|
||||
if (!text) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopiedFeedback(el)
|
||||
showCopiedFeedback(event?.currentTarget, event)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -342,7 +340,7 @@ export function countCrawlerUas(crawlers, clients) {
|
||||
const counts = {}
|
||||
for (const c of crawlers || []) {
|
||||
const client = (clients || {})[c.client] || {}
|
||||
const value = client.ua_pretty || client.ua || '(no UA)'
|
||||
const value = client.uarite?.pretty || client.ua || '(no UA)'
|
||||
counts[value] = (counts[value] || 0) + 1
|
||||
}
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||
@@ -421,8 +419,9 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
uaUrl: client.uarite?.url || '',
|
||||
lang: client.lang || '—',
|
||||
langDisplay: formatLang(client.lang),
|
||||
country: client.country || '—',
|
||||
@@ -505,6 +504,13 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
||||
const client = (clients || {})[g.lastClient] || {}
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
const uaRaws = [
|
||||
...new Set(
|
||||
[...g.clientHashes]
|
||||
.map((h) => (clients || {})[h]?.ua)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join('\n')
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
@@ -527,8 +533,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
||||
ip: client.ip || g.ip,
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
|
||||
isHost,
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
uaUrl: client.uarite?.url || '',
|
||||
uaRaws,
|
||||
lang: client.lang || '—',
|
||||
langDisplay: formatLang(client.lang),
|
||||
country: client.country || '—',
|
||||
@@ -582,8 +590,9 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
||||
lang: dash(client.lang),
|
||||
country: dash(client.country),
|
||||
city: dash(client.city),
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
uaUrl: client.uarite?.url || '',
|
||||
utm: utm || '—',
|
||||
utmTitle,
|
||||
}
|
||||
|
||||
@@ -756,6 +756,20 @@ article {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Emoji/symbol icon buttons and links: dim until hovered. */
|
||||
.icon-btn {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
@@ -763,12 +777,6 @@ article {
|
||||
left: -2.2rem;
|
||||
z-index: 2;
|
||||
/* stay above full-bleed .wide images */
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
@@ -790,24 +798,14 @@ article h2 .edit-section {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Login/profile links injected by pagerite.js when Paskia SSO is in use.
|
||||
They live inside the .editor-pens flex container in the banner's top-right
|
||||
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
|
||||
corner and inherit its reset; keep only their text-shadow tweak. */
|
||||
.editor-pens a.login-link,
|
||||
.editor-pens a.profile-link {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
.editor-pens a.login-link:hover,
|
||||
.editor-pens a.profile-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
article p,
|
||||
article li,
|
||||
article dd {
|
||||
|
||||
@@ -30,6 +30,20 @@ export const LANG_GROUPS = [
|
||||
|
||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||
|
||||
// Consistent menu ordering for language selectors: the geographic/cultural
|
||||
// grouping above (similar languages sit together, and it does not vary with
|
||||
// the display language the way alphabetical-by-name would). Tags outside
|
||||
// the groups trail, ordered by tag. The primary language is not special
|
||||
// here — callers put it first themselves.
|
||||
const groupOrder = new Map(LANG_GROUPS.flat().map((c, i) => [c, i]))
|
||||
export function langSort(codes) {
|
||||
return [...codes].sort(
|
||||
(a, b) =>
|
||||
(groupOrder.get(a) ?? groupOrder.size) - (groupOrder.get(b) ?? groupOrder.size)
|
||||
|| a.localeCompare(b),
|
||||
)
|
||||
}
|
||||
|
||||
// English display name for a language tag ("fi" -> "Finnish").
|
||||
export function langName(tag) {
|
||||
try {
|
||||
|
||||
@@ -132,16 +132,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
if (line != null) {
|
||||
// Section pen on an anchored h2: opens the page editor at the
|
||||
// section's markdown source line (data-line, from the backend).
|
||||
btn.className = "edit-link edit-section";
|
||||
btn.className = "edit-link edit-section icon-btn";
|
||||
btn.title = "edit section";
|
||||
btn.textContent = "🖊️";
|
||||
btn.dataset.editorLine = line;
|
||||
} else if (mode === "page") {
|
||||
btn.className = "edit-link edit-page";
|
||||
btn.className = "edit-link edit-page icon-btn";
|
||||
btn.title = "edit page";
|
||||
btn.textContent = "🖊️";
|
||||
} else {
|
||||
btn.className = "edit-link site-edit-link";
|
||||
btn.className = "edit-link site-edit-link icon-btn";
|
||||
btn.title = "site settings";
|
||||
btn.textContent = "⚙️";
|
||||
}
|
||||
@@ -165,7 +165,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
|
||||
function makeAuthLink(admin) {
|
||||
const a = document.createElement("a");
|
||||
a.className = admin ? "profile-link" : "login-link";
|
||||
a.className = (admin ? "profile-link" : "login-link") + " icon-btn";
|
||||
a.href = "/auth/";
|
||||
a.title = admin ? "profile" : "log in";
|
||||
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
|
||||
@@ -211,7 +211,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
if (canEdit && !onAnalytics) {
|
||||
// Analytics viewer is now a normal page at /_a.
|
||||
const a = document.createElement("a");
|
||||
a.className = "edit-link analytics-link";
|
||||
a.className = "edit-link analytics-link icon-btn";
|
||||
a.href = "/_a";
|
||||
a.title = "analytics";
|
||||
a.textContent = "📊";
|
||||
|
||||
+26
-34
@@ -60,32 +60,17 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import blake3
|
||||
import msgspec
|
||||
from ua_parser import parse
|
||||
from uarite import UA, uaparse
|
||||
|
||||
|
||||
def _compact_user_agent(ua: str) -> str:
|
||||
"""Format a User-Agent string into a compact display form.
|
||||
def _display_client(client: Client) -> Client:
|
||||
"""Client copy with ``uarite`` filled in by the current uarite.
|
||||
|
||||
Returns the original UA when the parser cannot identify the browser/OS.
|
||||
The parsed UA is a display-time field: stored records always carry the
|
||||
default (None), so it never lands on disk, and old records always
|
||||
follow current uarite rules.
|
||||
"""
|
||||
if not ua or not ua.strip() or ua == "-":
|
||||
return ""
|
||||
r = parse(ua)
|
||||
browser = r.user_agent.family if r.user_agent else None
|
||||
ver = r.user_agent.major if r.user_agent else ""
|
||||
os_name = r.os.family if r.os else None
|
||||
dev = r.device.family if r.device else None
|
||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
||||
return ua
|
||||
if browser and browser != "Other":
|
||||
browser = browser.split()[0]
|
||||
else:
|
||||
browser = ""
|
||||
os_name = os_name if os_name and os_name != "Other" else ""
|
||||
if dev in (None, "Other") or dev == browser:
|
||||
dev = ""
|
||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
return msgspec.structs.replace(client, uarite=uaparse(client.ua))
|
||||
|
||||
|
||||
class Ping(msgspec.Struct, omit_defaults=True):
|
||||
@@ -172,11 +157,14 @@ class Client(msgspec.Struct, omit_defaults=True):
|
||||
city: str = ""
|
||||
#: Raw User-Agent header.
|
||||
ua: str = ""
|
||||
#: Compact display form of ``ua`` (browser/OS/device) when parsable.
|
||||
ua_pretty: str = ""
|
||||
#: True for admin clients (hide=1 ping): everything this client ever did
|
||||
#: is excluded from all statistics and from the viewer payload.
|
||||
hide: bool = False
|
||||
#: Display-time parsed UA (uarite.UA dataclass: pretty/engine/os/
|
||||
#: provider/kind/url). Set only on the display-payload copies by
|
||||
#: ``_display_client`` — stored records keep the default, so it is never
|
||||
#: persisted and old data always follows the current uarite version.
|
||||
uarite: UA | None = None
|
||||
|
||||
|
||||
# --- Display DTOs -------------------------------------------------------
|
||||
@@ -418,17 +406,22 @@ _MIN_VISIT_READ = 5
|
||||
_FAVICON_RETRY = timedelta(days=7)
|
||||
|
||||
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||
#: activity messages. Anything calling itself a "bot" or "spider" matches;
|
||||
#: known crawlers without those tokens (GoogleOther) are listed as extra
|
||||
#: alternates. No source verification: a spoofed bot UA just lands in the
|
||||
#: crawler list, and scanners that probe telltale paths are caught by the
|
||||
#: abuse rules anyway.
|
||||
_BOT_UA = re.compile(r"bot|spider|googleother", re.IGNORECASE)
|
||||
#: activity messages. ``uarite`` knows the common crawlers
|
||||
#: and link-preview fetchers (including disguised ones such as
|
||||
#: facebookexternalhit and Google-Extended) plus any UA with a
|
||||
#: bot/spider/crawler/scanner token. No source verification: a spoofed
|
||||
#: bot UA just lands in the crawler list, and scanners that probe
|
||||
#: telltale paths are caught by the abuse rules anyway.
|
||||
|
||||
|
||||
def _is_bot_ua(ua: str) -> bool:
|
||||
"""True when the UA claims a crawler identity (bot or spider)."""
|
||||
return bool(_BOT_UA.search(ua))
|
||||
"""True when the UA is not a regular browser.
|
||||
|
||||
Every real browser registers as ``kind == "browser"``; anything else
|
||||
(recognized crawler/previewer, generic bot token, or an unclassified
|
||||
HTTP client such as httpx) is not a visitor.
|
||||
"""
|
||||
return uaparse(ua).kind != "browser"
|
||||
|
||||
|
||||
#: Plain-404 count per IP within ``_ABUSE_404_WINDOW`` that classifies it as
|
||||
@@ -560,7 +553,6 @@ class Store:
|
||||
self.data.clients[h] = Client(
|
||||
ip=ip,
|
||||
ua=ua,
|
||||
ua_pretty=_compact_user_agent(ua),
|
||||
lang=lang,
|
||||
country=country,
|
||||
)
|
||||
@@ -940,7 +932,7 @@ class Store:
|
||||
and not self._hidden(g.client)
|
||||
and ip_of.get(g.client, "") in abuse_ips
|
||||
],
|
||||
clients={h: c for h, c in data.clients.items() if not c.hide},
|
||||
clients={h: _display_client(c) for h, c in data.clients.items() if not c.hide},
|
||||
favicons={
|
||||
origin: f"/_f/{f.file}"
|
||||
for origin, f in data.favicons.items()
|
||||
|
||||
+19
-3
@@ -17,6 +17,13 @@ from pagerite.segments import has_prose
|
||||
#: backticks or tildes (CommonMark).
|
||||
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||
|
||||
#: A container fence line (mdit-py-plugins container): the "::: aside"
|
||||
#: opener and the ":::" closer alike. Always its own block, even with no
|
||||
#: blank line around it: folded into a prose paragraph it would cross to
|
||||
#: the translator as part of the text run, where the model can drop it —
|
||||
#: the rest of the page then renders inside the container.
|
||||
_CONTAINER = re.compile(r"^ {0,3}:{3,}(?:[ \t]|$)")
|
||||
|
||||
#: HTML block openers that may span blank lines (CommonMark types 1-5:
|
||||
#: script/pre/style/textarea, comments, processing instructions,
|
||||
#: declarations, CDATA) with their closing condition. Other HTML blocks
|
||||
@@ -54,9 +61,11 @@ def chunk_markdown(markdown: str) -> list[str]:
|
||||
Blocks are separated by blank lines; fenced code blocks and the
|
||||
multi-line HTML blocks (comments, script/pre/style, CDATA...) are
|
||||
kept atomic, even across blank lines, and end at their closing
|
||||
condition. Chunks carry no surrounding blank lines and no trailing
|
||||
newline; rejoining with ``join_chunks`` reproduces the source modulo
|
||||
blank-line normalization.
|
||||
condition. Container fence lines (:::, open and close alike) are
|
||||
always their own block, blank lines or not (see _CONTAINER). Chunks
|
||||
carry no surrounding blank lines and no trailing newline; rejoining
|
||||
with ``join_chunks`` reproduces the source modulo blank-line
|
||||
normalization.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
buf: list[str] = []
|
||||
@@ -91,6 +100,13 @@ def chunk_markdown(markdown: str) -> list[str]:
|
||||
fence = m.group(1)
|
||||
buf.append(line)
|
||||
continue
|
||||
if _CONTAINER.match(line):
|
||||
# Container fence lines (open and close alike) are their own
|
||||
# block — never part of a prose chunk (see _CONTAINER).
|
||||
flush()
|
||||
buf.append(line)
|
||||
flush()
|
||||
continue
|
||||
if not buf:
|
||||
for open_re, close_re in _HTML_ATOMIC:
|
||||
if open_re.match(line):
|
||||
|
||||
+21
-20
@@ -3,18 +3,19 @@
|
||||
The visitor-activity WebSocket (``/_ws``, public) and the admin analytics
|
||||
stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are
|
||||
enriched in background tasks with reverse DNS (cached PTR lookups) and the
|
||||
DB-IP city MMDB (``GeoIP``, decompressed and opened once at startup);
|
||||
DB-IP city MMDB (``GeoIP``, decompressed into RAM and opened once at
|
||||
startup);
|
||||
external referrers get their favicon fetched and stored content-hashed.
|
||||
Snapshot broadcasts to connected admin sockets are debounced.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
@@ -25,6 +26,7 @@ import httpx
|
||||
import msgspec
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
from uarite import uaparse
|
||||
|
||||
from pagerite import analytics
|
||||
from pagerite.data import resolve
|
||||
@@ -103,15 +105,19 @@ def _download_dbip() -> None:
|
||||
|
||||
|
||||
def _geoip_db_path() -> Path | None:
|
||||
"""Find a DB-IP MMDB in the working directory, preferring an already-decompressed
|
||||
``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present.
|
||||
"""Find a DB-IP MMDB in the working directory: the ``.mmdb.gz`` download
|
||||
is canonical (decompressed into RAM at open); a plain ``.mmdb`` left over
|
||||
from older versions is still usable, and removed once the matching ``.gz``
|
||||
is present so it does not linger on disk. Returns None if none is present.
|
||||
"""
|
||||
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
|
||||
if gz:
|
||||
for stale in _DBIP_DIR.glob("dbip-*.mmdb"):
|
||||
stale.unlink()
|
||||
return gz[0]
|
||||
mmdb = sorted(_DBIP_DIR.glob("dbip-*.mmdb"))
|
||||
if mmdb:
|
||||
return mmdb[0]
|
||||
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
|
||||
if gz:
|
||||
return gz[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -124,27 +130,22 @@ class GeoIP:
|
||||
def __init__(self) -> None:
|
||||
self._reader: object | None = None
|
||||
|
||||
def _decompress(self, source: Path, target: Path) -> None:
|
||||
if target.exists():
|
||||
return
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
with gzip.open(source, "rb") as src, open(tmp, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
os.replace(tmp, target)
|
||||
|
||||
def _load(self) -> None:
|
||||
if self._reader is not None:
|
||||
return
|
||||
source = _geoip_db_path()
|
||||
if source is None:
|
||||
return
|
||||
if source.suffix == ".gz":
|
||||
target = source.with_suffix("")
|
||||
self._decompress(source, target)
|
||||
source = target
|
||||
try:
|
||||
import maxminddb
|
||||
|
||||
if source.suffix == ".gz":
|
||||
# Only the .gz is kept on disk; the database is decompressed
|
||||
# into RAM (MODE_FD makes the pure-Python Reader .read() the
|
||||
# buffer — never mmap — and bypasses the C extension).
|
||||
buf = io.BytesIO(gzip.decompress(source.read_bytes()))
|
||||
self._reader = maxminddb.open_database(buf, maxminddb.MODE_FD)
|
||||
else:
|
||||
self._reader = maxminddb.open_database(str(source))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -443,7 +444,7 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
# already printed there): compact UA plus the browser's language tag.
|
||||
lang, _country = analytics._parse_accept_language(accept_language)
|
||||
ws.scope.setdefault("state", {})["log_extra"] = " ".join(
|
||||
part for part in (analytics._compact_user_agent(ua), lang) if part
|
||||
part for part in (uaparse(ua).pretty, lang) if part
|
||||
)
|
||||
await ws.accept()
|
||||
try:
|
||||
|
||||
+22
-8
@@ -263,20 +263,31 @@ def _transition_css_url(transition: str) -> str | None:
|
||||
|
||||
|
||||
def _editor_css_url(vite_url: str | None) -> str | None:
|
||||
"""URL for the editor-specific stylesheet (Vue component styles).
|
||||
"""URLs (comma-joined) for the editor-specific stylesheets (Vue
|
||||
component styles).
|
||||
|
||||
This is linked by the public-page edit pen so the editor styles are
|
||||
loaded before the editor JS dynamic-import resolves.
|
||||
loaded before the editor JS dynamic-import resolves. Component styles
|
||||
can land on shared chunks rather than the entry's own stylesheet —
|
||||
LangSelect's ride on the shared store chunk, as it is also used by the
|
||||
on-demand public language selector — so collect the stylesheets of the
|
||||
entry and its imported chunks (the same traversal _langselect_assets
|
||||
does).
|
||||
"""
|
||||
if vite_url:
|
||||
return None
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
base = manifest.get(_BASE_CSS_KEY, {}).get("file")
|
||||
for css in entry.get("css", []):
|
||||
if css != base:
|
||||
return f"/{css}"
|
||||
return None
|
||||
stylesheets, seen = [], set()
|
||||
queue = ["src/main.js"]
|
||||
for key in queue: # grows with imported chunks
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entry = manifest[key]
|
||||
stylesheets += [f"/{css}" for css in entry.get("css", []) if css != base]
|
||||
queue += entry.get("imports", [])
|
||||
return ",".join(stylesheets) or None
|
||||
|
||||
|
||||
def _inline_asset(url: str) -> str:
|
||||
@@ -1047,9 +1058,12 @@ def _language_urls(
|
||||
canonical = url if lang == original else f"{url}?lang={lang}"
|
||||
alternates = []
|
||||
if data.translate_langs:
|
||||
# Only languages the page actually has AND that are still enabled
|
||||
# site-wide (a disabled target stops being advertised).
|
||||
enabled = {original, *data.translate_langs}
|
||||
alternates = [("x-default", url)] + [
|
||||
(tag, url if tag == original else f"{url}?lang={tag}")
|
||||
for tag in sorted({original, *node.langs})
|
||||
for tag in sorted({original, *node.langs} & enabled)
|
||||
]
|
||||
return canonical, alternates
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ dependencies = [
|
||||
"platformdirs>=4.11.5",
|
||||
"pygments>=2.20.0",
|
||||
"python-slugify>=8.0.4",
|
||||
"ua-parser>=1.0.2",
|
||||
"uarite>=0.1.2",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user