Livejournal Import
Import LiveJournal blog entries with images, tags, mood, now playing, and comments into Obsidian vault
Global Arguments
| Argument | Type | Description |
|---|---|---|
| vault | string | Obsidian vault name |
| folder | string | Target folder in Obsidian vault |
| vaultRoot? | string | Absolute path to the Obsidian vault directory. When set, the note is written directly to disk (no Obsidian CLI, no desktop app needed) instead of resolving the vault path and creating the note through the Obsidian CLI. |
| timeoutMs | number | Per-request timeout (ms) applied to every HTTP fetch (index/post/image) and the obsidian CLI subprocess, via AbortController. Backward-compatible default -- existing instances behave exactly as before unless this is set explicitly. |
| maxPages | number | Maximum number of journal index pages collectPostUrls will paginate through before stopping (safety cap against unbounded pagination; ~10 posts/page). Backward-compatible default well above any real journal's page count. |
Resources
2026.08.02.2
Real fixes for the six remaining latent bugs tracked in the LOCAL
livejournal-import-latent-bugs issue-lifecycle model (NEVER filed to the
swamp.club Lab -- see CLAUDE.md's anti-bypass rule): LB2 (YAML frontmatter
injection), LB3 (silent-empty success), LB4 (no fetch/subprocess
timeout), LB5 (unbounded pagination), LB6 (fragile comment-JSON
extraction), and LB8 (parseLjDate silent fallthrough). model.version and
manifest.yaml both bump 2026.08.02.1 -> 2026.08.02.2. LB1 (SSRF, fixed in
2026.07.31.1) and LB7 (path traversal, fixed in 2026.08.02.1) are untouched.
- Two new backward-compatible global arguments:
timeoutMs(default30000) andmaxPages(default1000), both.default(...)-ed inGlobalArgsSchemaand re-defaulted at the JS destructuring site inimport'sexecute(so an existing instance, or any caller that hands the method raw global args without going through the zod schema, behaves identically unless one is set explicitly). Neither is a resource attribute, so the appendedupgrades[]entry is an identity (upgradeAttributes: (old) => old). - LB2 fix (YAML frontmatter injection via unescaped newlines, MEDIUM): a new
yamlEscape(s)helper escapes backslash,",\n,\r, and other C0 control characters (not just"as before) intitle/mood/now_playing/ each tag's INNER frontmatter content -- the surrounding"…"quoting is unchanged, so benign input (no backslash/control chars) still produces byte-identical frontmatter. A raw embedded newline in any of those fields can no longer inject a sibling YAML key into the frontmatter. - LB3 fix (silent-empty success, MEDIUM):
importnow logs a distinct "no posts found" warning whencollectPostUrlsreturns zero URLs, instead of this reading as an ordinary success with no signal that something may be wrong. Logger-only --result.errorsstays[]. - LB4 fix (no fetch/subprocess timeout, MEDIUM): every
fetchcall (index/post fetch viafetchWithRetry, image fetch viafetchImageSafely) and bothDeno.Commandinvocations (runObsidian,getVaultPath) now carry atimeoutMs-boundedAbortController/signal, alwaysclearTimeout-ed in afinallyblock. A manualsetTimeout/clearTimeoutpair is used deliberately instead ofAbortSignal.timeout(), which leaves a pending internal timer the op-sanitizer flags and interacts badly with@std/testing'sFakeTime.fetchImageSafelykeepsredirect: "manual"alongside the newsignal-- both are required, neither drops the other. - LB5 fix (unbounded pagination/memory, MEDIUM):
collectPostUrlsnow stops aftermaxPagesindex pages (checked before each fetch, so exactlymaxPagespages are fetched, not one more) and logs a distinct cap-warning when the limit is hit. - LB6 fix (fragile comment-JSON extraction, LOW): the
Site.page = {...}blob is now located withextractSitePageJson, a string-aware balanced-brace scan (tracking JSON string/escape state) instead of a regex requiring a literal};+ whitespace terminator -- a minified, semicolon-less blob (Site.page={...}</script>) that the old regex silently dropped now parses correctly.parsePostreturns a newcommentParseFailedflag (stays pure/logger-free) set whenever aSite.pagemarker was present but comments could not be recovered from it (unterminated/unbalanced object, or a genuineJSON.parsefailure); the caller logs a distinct warning when it is set. NoSite.pagemarker at all (an ordinary page with no comments) is not a failure. - LB8 fix (
parseLjDatesilent fallthrough, LOW): a date string not matching the expected shape now resolves to a module-level sentinel,"unknown"(colon-free and space-free, so it stays valid unquoted YAML and slug-safe), instead of the raw text passing through unchanged. The caller logs a distinct warning when the sentinel is hit.PostSchema.dateis a requiredz.string(), so the sentinel is a mandatory replacement value, not an omission. Valid dates are completely unaffected -- byte-identical to before. - Byte-stability guarantee for benign input: none of the six fixes change
output for well-formed input.
yamlEscapeis byte-identical to the old.replace(/"/g, '\\"')when the input has no backslash/control character;extractSitePageJsonextracts the identical substring the old regex did for every well-formed fixture in this repo; valid dates parse identically; the defaulttimeoutMs/maxPagesare far above anything any existing test or real journal would ever hit. - Tests: all six LB pins in
livejournal_import_adversarial_test.tsflip from characterizing the bug to asserting the fix (title suffixed-- FIXED), reusing the existing fixtures (`post_injection.h
2026.08.02.1
Fix for latent bug LB7 (operator folder/attachmentsFolder path traversal on
the attachment disk write, LOW) tracked in the LOCAL
livejournal-import-latent-bugs issue-lifecycle model (NEVER filed to the
swamp.club Lab -- see CLAUDE.md's anti-bypass rule). model.version and
manifest.yaml both bump 2026.08.01.1 -> 2026.08.02.1.
- LB7 fix: the attachment disk path (
attachDiskPath, used for the attachments-foldermkdirand every downloaded image'swriteFile) is now resolved through the already-copied string-levelresolveVaultPathbefore the post loop, instead of being built with a raw template-string concatenation (`${vaultPath}/${folder}/${attachmentsFolder}`). Afolder/attachmentsFoldercontaining..segments or an absolute path now rejects the whole run fast with"Path escapes vault root"/"Path is outside vault root", instead of silently landing outside the vault. - Both branches are fixed:
vaultPathisvaultRoot || getVaultPath(vault)and is non-empty in either case, so the same guard covers the CLI-fallback branch (novaultRootset) and the headlessvaultRootfilesystem branch added in2026.08.01.1. - Deliberately
resolveVaultPath, notresolveVaultPathSafe: the attachment path check is string-level only (noDeno.realPath/lstat), matching the CLI-fallback branch's semantics where the vault directory need not already exist on disk, and keeping the byte-identical"/fixture/vault/LiveJournal/attachments"contract in the fixture-based test suites frozen. A malicious symlinkedfolderpath segment is still not caught on the attachment side (only the note write'sresolveVaultPathSafecatches that) -- unchanged from before this fix, and out of LB7's scope (LB7 is specifically about../absolute traversal, not symlink-following). - Fail-fast semantics: because the guard runs before the post loop and
outside any per-post
try, a hostilefolder/attachmentsFoldernow aborts the entireimportrun (the method's promise rejects) rather than recording a soft per-post error. Noresultresource is written in that case -- this is a deliberate, documented difference from the per-post error-accumulation behavior elsewhere in this model. - Adversarial suite: the two LB7 pins are flipped from characterizing the
traversal to asserting rejection (
assertRejectson"Path escapes vault root"for the CLI-fallback branch, and on the vaultRoot branch's../escapedregression case from2026.08.01.1, now additionally asserting the escaped directory never gets created at all -- a non-vacuous proof the guard fires beforeDeno.mkdir). Two new absolute-path variants ("/etc/lj-escape"-shaped) were added, one per branch, so both the CLI-fallback and vaultRoot branches are exercised against both traversal shapes (..-relative and absolute). - Coverage suite: added a benign-nested-folder regression (
folder:"sub/dir") proving a multi-segment, non-traversalfolderstill works end-to-end (attachments directory created, note written) -- guards against an over-broad fix that would reject every multi-segment folder rather than just../absolute escapes. - The other 7 latent bugs tracked in
livejournal-import-latent-bugs(LB1 SSRF -- already fixed in2026.07.31.1; LB2 YAML-newline-injection, LB3 silent-empty, LB4 no-timeout, LB5 unbounded-pagination, LB6 fragile comment-JSON, LB8 parseLjDate-fallthrough) are untouched -- their pins still assert current behavior. - Added an identity
upgrades[]entry (2026.08.01.1 -> 2026.08.02.1,upgradeAttributes: (old) => old, no resource schema change) -- keeps the model-upgrade chain continuous withfinal toVersion === model.version. manifest.yaml/modelversion:2026.08.01.1->2026.08.02.1.
2026.08.01.1
Adds an optional headless vaultRoot filesystem backend to import, so the
import can run with the Obsidian desktop app closed (swamp-workspace #57;
mirrors the CLI/filesystem backend split done for @magistr/obsidian-vault in
PR #56 — see that PR for the path-confinement rationale). The Obsidian CLI
(getVaultPath + runObsidian("create", ...)) is kept as the fallback for when
vaultRoot is not set. Cross-reference: swamp-workspace#57.
- Added the
vaultRootglobal argument. When set, the vault path resolves to it directly (skipping theobsidian vault ... info=pathCLI call), and the note is written with a confined atomic write instead ofrunObsidian("create", ...). - Added
resolveVaultPath/resolveVaultPathSafe(realpath + symlink refusal..rejection) and the atomic-write helpers (writeAtomic,ensureParentDir,chmodQuietly), copied VERBATIM (same names/comments, per the approved plan's scope constraint against a shared cross-extension module — swamp bundles each extension independently) fromobsidian-vault/extensions/models/obsidian_vault.ts(PR #56). The note write now resolves throughresolveVaultPathSafebefore everymkdir/write, closing folder-traversal and symlink-escape vectors on the new headless path.
- This guard is scoped to the note write ONLY: LB7 (folder path traversal on
the
attachDiskPathmkdir + image write, LOW, tracked in the locallivejournal-import-latent-bugsmodel) is UNCHANGED and remains pinned for both the CLI branch and the new vaultRoot branch — a maliciousfolderglobal argument still letsDeno.mkdir/Deno.writeFileland outside the vault for attachments, even when vaultRoot is set. Only the note itself is now confined. - No
npm:yamldependency was added — this model emits brand-new hand-built frontmatter into notes it owns, it never round-trips existing frontmatter, so PR #56's yaml-Documentrationale does not apply here. Every hand-built frontmatter string stays byte-for-byte identical to before this change. - Dot-dir/
.trashexclusion is N/A:importwrites into a caller-named folder, it never walks the vault tree (covered by a covered-negative test in the adversarial suite). - Real-world behavior note: writing the note directly via
Deno.writeTextFile(through the new atomic-write helper) may not be byte-identical to what the real Obsidian CLI'screatecommand would have produced on disk — the CLI has never been observed to differ in this repo's tests (it's always stubbed), but a realobsidian createcall could in principle normalize a trailing newline differently than a rawDeno.writeTextFile. Not reproduced or fixed here, just flagged. - Extended all five test suites (contract-fixture, methods, adversarial,
coverage, property-invariant-flow) with
vaultRootcoverage: method-level tests proving the CLI is never invoked whenvaultRootis set (Deno.Commandstubbed to throw on"obsidian") and that bytes match the CLI branch exactly, a backend-selection precedence branch matrix, path-confinement adversarial tests (..traversal and symlinked folder segment refused,/var-vs-/private/varreal-root containment, plus an explicit regression pin that LB7's attachment-mkdir traversal is UNCHANGED), and a property test asserting exactly one note per generated post with frontmatter round-trip and no path escaping the vault's real root. No new fixture files were needed. deno.json:test/test:soaktasks gain--allow-write(previously--allow-read --allow-envonly) so the new fs-write tests can run.- Added an identity
upgrades[]entry (2026.07.31.1 -> 2026.08.01.1,upgradeAttributes: (old) => old, no resource schema change) — required so the model-upgrade chain stays continuous withfinal toVersion === model.version, per this repo's ratchet-label convention. manifest.yaml/modelversion:2026.07.31.1->2026.08.01.1.
2026.07.31.1
Fix for latent bug LB1 (SSRF via image src, HIGH) tracked in the LOCAL
livejournal-import-latent-bugs issue-lifecycle model (NEVER filed to the
swamp.club Lab -- see CLAUDE.md's anti-bypass rule), plus a model upgrade-chain
repair and a quality-ratchet un-freeze. model.version and manifest.yaml both
bump 2026.07.16.2 -> 2026.07.31.1.
- SSRF fix: added
isAllowedImageHost(imageUrl, journalUrl), a pure allowlist predicate replacing the old denylist-only filtering. An image URL is fetched only if it useshttp(s), is NOT an IP-literal host (dotted-decimal/decimal/hex IPv4, and any IPv6 form including compressed and IPv4-mapped --new URL().hostnamenormalization is relied on, not substring matching), and its host is either a known LiveJournal media CDN (*.livejournal.com/*.livejournal.net, suffix-anchored) or shares the configured journal's registrable domain (a conservative last-two-labels approximation, not full public-suffix-list parsing -- the journal host is operator-supplied trusted input). Applied to BOTH image-collection paths inparsePost: the<img src>path (the existing chrome denylist foruserpic/pixel/spacer/stat.livejournalis kept as a secondary exclusion) and the wrapped<a href>path, which was previously UNGUARDED by any check at all -- a second SSRF entry point. - Redirect hardening: the image-download fetch now passes
redirect: "manual"and re-validates the allowlist at every hop (bounded to 5), closing the pivot where an allowlisted host 30x-redirects to an internal target. Reuses the sameisAllowedImageHostfor redirect targets as for the initial URL -- no separate/weaker check. - Behavior change: an image whose host neither shares the journal's
registrable domain nor is a known LiveJournal media CDN is no longer imported
(silently dropped, matching the prior denylist's silent-drop semantics --
result.errorsstays empty). A journal that embeds off-domain images (e.g. Photobucket/Imgur/a personal server) will lose those images from imported notes. This is the intended SSRF hardening. - Accepted residual: DNS-rebinding and allowlisted-suffix-collision at
connect time are not closed by hostname allowlisting alone -- Deno's
fetchoffers no connect-time IP validation hook.fetchWithRetry(index/post crawl) also keeps default redirect-follow, sincejournalUrlis operator-supplied/trusted input, narrower and out of scope here. - Accepted residual, elevated for operator awareness:
journalApex's last-two-labels registrable-domain approximation (not a full public-suffix-list algorithm -- see the code comment abovejournalApexinlivejournal_import.ts) misclassifies journals hosted on a multi-label public suffix or a shared/wildcard-DNS hosting platform (e.g.co.uk,github.io,s3.amazonaws.com,sslip.io/nip.io-style services that resolve an IP-encoded hostname to that literal IP via a normal A record). For such ajournalUrl, the derived apex (e.g.sslip.io) is shared with every other tenant on that platform, so a post-body image pointing at another host under the same apex is incorrectly allowed. Operators importing a journal hosted on such a platform should verify no unexpected image hosts appear in imported notes. A full fix requires a real public-suffix-list dependency, which is out of scope for this minimal SSRF-hardening pass (this exact tradeoff, and the conservative endsWith-apex alternative to PSL-parsing, was reviewed and accepted during planning; the trailing-dot FQDN-notation variant of this issue -- which was a genuine bug, not an accepted tradeoff -- is fixed, not merely documented, and pinned in the adversarial suite). - Upgrade-chain repair:
model.upgrades[]previously ended at2026.03.29.1whilemodel.versionwas already2026.07.16.2, soswamp extension qualityerrored on the broken chain instead of scoring the extension. Added two identity lineage-repair bridge entries (2026.03.29.1 -> 2026.05.25.1 -> 2026.07.16.2,upgradeAttributes: (old) => old, no resource schema change) plus the new2026.07.16.2 -> 2026.07.31.1entry, so the chain is continuous and its finaltoVersionequalsmodel.version. - Quality ratchet un-frozen: with the chain repaired,
swamp extension quality manifest.yaml --jsonnow emits a real score (14/14 points, 100%,allPassed: true) instead of erroring.quality.yaml'sratchetis restamped from that tool output:baselinePercentage: 0/UNSCORABLE->baselinePercentage: 100/ Grade A. - Tests: the LB1 pins in
livejournal_import_adversarial_test.tsare flipped from "an internal target IS fetched with no allowlist" to "internal targets are NEVER fetched,imageCountis 0", plus new tests for the allowli
Release 2026.07.16.2 — align model versions with manifests
Maintenance release across the @magistr extensions. For most packages this
carries no functional change: the only edit is the model's version: field,
brought back in line with its manifest version so the published model type
version and the package version no longer drift.
Functional changes in this release are limited to:
anime-cron: normalizeTitle now strips a ": subtitle" suffix and a trailing parenthesized year before comparison, fixing dedup false-misses where the torrent title carries a subtitle or year that the AniList romaji does not.
arckit: first publish. Standalone ArcKit port — a 12-phase architecture governance state machine with 65 bundled templates, driven by a bundled skill.
Also tracks three extensions (kaiten, observability-agent, music-library) that previously existed only as untracked working-tree directories, recovered from stashes.
Maintenance version bump. No functional changes.
Added 1, removed 1 models
Merge pull request #4 from umag/extensions/magistr-grade-a-workspace
extensions: stage 15 @magistr extensions as Grade A workspace dirs + wire CI
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License declared1/1earned
- Verified public repository2/2earned