diff --git a/public/registry/index.json b/public/registry/index.json index c5d8162..0c99952 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "plugins": { "count": 27, - "updatedAt": "2026-08-14T16:53:54.431Z" + "updatedAt": "2026-08-14T18:32:52.260Z" }, "themes": { "count": 54, - "updatedAt": "2026-08-14T16:53:54.431Z" + "updatedAt": "2026-08-14T18:32:52.260Z" }, "widgets": { - "count": 45, - "updatedAt": "2026-08-14T16:53:54.431Z" + "count": 47, + "updatedAt": "2026-08-14T18:32:52.260Z" }, "skills": { "count": 32, - "updatedAt": "2026-08-14T16:53:54.431Z" + "updatedAt": "2026-08-14T18:32:52.260Z" }, "queries": { "count": 24, - "updatedAt": "2026-08-14T16:53:54.431Z" + "updatedAt": "2026-08-14T18:32:52.260Z" } } diff --git a/public/registry/plugins.json b/public/registry/plugins.json index 1463dfe..e7ab01c 100644 --- a/public/registry/plugins.json +++ b/public/registry/plugins.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "plugins": [ { "id": "ai-actions", @@ -22,7 +22,7 @@ "apiUrl": "https://store.notedeck.io/registry/plugins/ai-actions/api.json", "sha512": "5c9a60976f798c3adbee963451daab12bbdfff538c877206b525c8590d6694129d6eb43991730b71d9f4221e2545822d2d6dcb5983d514af0bde3159bc96082f", "createdAt": "2026-08-13T03:19:12+09:00", - "updatedAt": "2026-08-15T01:45:34+09:00", + "updatedAt": "2026-08-15T01:54:09+09:00", "authorUrl": "https://github.com/notedeck-dev", "license": "MIT", "permissions": [ diff --git a/public/registry/queries.json b/public/registry/queries.json index d46a5b4..4c07b6d 100644 --- a/public/registry/queries.json +++ b/public/registry/queries.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "queries": [ { "id": "no-cw", diff --git a/public/registry/skills.json b/public/registry/skills.json index 7971955..61206a5 100644 --- a/public/registry/skills.json +++ b/public/registry/skills.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "skills": [ { "id": "ai-cost-pulse", diff --git a/public/registry/themes.json b/public/registry/themes.json index efb9fb6..eb908ac 100644 --- a/public/registry/themes.json +++ b/public/registry/themes.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "themes": [ { "id": "adwaita-dark", diff --git a/public/registry/widgets.json b/public/registry/widgets.json index a2a94cf..75fc47e 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-14T16:53:54.431Z", + "updatedAt": "2026-08-14T18:32:52.260Z", "widgets": [ { "id": "atcoder", @@ -340,6 +340,35 @@ "license": "MIT", "iconUrl": "https://store.notedeck.io/registry/widgets/misskey-online/icon.svg" }, + { + "id": "nodeinfo", + "name": "nodeinfo 調査", + "version": "1.0.0", + "author": "@hitalin", + "description": "任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・バージョン・規模・登録受付状態を表示。Misskey / Mastodon / Pleroma など ActivityPub 実装に共通で通り、認証不要", + "icon": "ti-scan", + "autoRun": true, + "category": "stats", + "capabilities": [ + "notedeck-api" + ], + "tags": [ + "fediverse", + "nodeinfo", + "server", + "stats", + "misskey", + "mastodon" + ], + "sourceUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/widget.is", + "apiUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/api.json", + "sha512": "107ca7daa16937e18fab8abf7838ad83885fbb19046b2b0150260fb68799e1230ea93d89b41e64fa0dab815cd1eaca0e92099765df9cedbfd35830a6470738f3", + "createdAt": "2026-08-15T03:32:33+09:00", + "updatedAt": "2026-08-15T03:32:33+09:00", + "authorUrl": "https://github.com/hitalin", + "license": "MIT", + "iconUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/icon.svg" + }, { "id": "okj-prices", "name": "OKJ 円建て価格ボード", @@ -369,6 +398,35 @@ "license": "MIT", "iconUrl": "https://store.notedeck.io/registry/widgets/okj-prices/icon.svg" }, + { + "id": "osv-vulns", + "name": "OSV 脆弱性", + "version": "1.0.0", + "author": "@hitalin", + "description": "OSV.dev にパッケージ名を投げて、そのパッケージの既知脆弱性を重大度・修正バージョン付きで新しい順に表示。既定は npm:misskey-js。npm / PyPI / crates.io など 8 エコシステム対応、API キー不要", + "icon": "ti-package-search", + "autoRun": true, + "category": "display", + "capabilities": [ + "notedeck-api" + ], + "tags": [ + "security", + "vulnerability", + "osv", + "cve", + "supply-chain", + "misskey" + ], + "sourceUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/widget.is", + "apiUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/api.json", + "sha512": "3144f60a44effc996ad98616e5af4360cb18fc9acf2288ff6c81ad7ea54d3fdbbb9da1c0bffb1bd67d64c4452697d4996a1a30d2852364afb80f7e7d4c00a39b", + "createdAt": "2026-08-14T18:32:54.089Z", + "updatedAt": "2026-08-14T18:32:54.089Z", + "authorUrl": "https://github.com/hitalin", + "license": "MIT", + "iconUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/icon.svg" + }, { "id": "rss-reader", "name": "RSSリーダー", diff --git a/public/registry/widgets/nodeinfo/api.json b/public/registry/widgets/nodeinfo/api.json new file mode 100644 index 0000000..ff52344 --- /dev/null +++ b/public/registry/widgets/nodeinfo/api.json @@ -0,0 +1 @@ +{"type":"widget","data":"/// @ 1.2.1\n/// nodeinfo ウィジェット\n/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。\n/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。\n///\n/// 実装メモ:\n/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。\n/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない\n/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す\n/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、\n/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため)\n/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。\n/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない\n/// - 内容はほぼ静的なので自動更新は 6 時間おき\n\nlet WELL_KNOWN_REL_20 = \"http://nodeinfo.diaspora.software/ns/schema/2.0\"\nlet WELL_KNOWN_REL_21 = \"http://nodeinfo.diaspora.software/ns/schema/2.1\"\nlet HOST_KEY = \"nodeinfo:host\"\nlet DEFAULT_HOST = \"misskey.io\"\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない\nlet MAX_BODY = 200000\n\n// カラーパレット (ink-new-servers と同系統)\nlet COL_OPEN = \"86b300\" // 登録開放\nlet COL_DOWN = \"f74e52\" // エラー\nlet COL_SUB = \"6b7480\" // 補助・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// 相手サーバー由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す\n/// (ポート付き・IDN・localhost は弾く)\n@validHost(h) {\n if (!h.incl(\".\")) { return false }\n if (h.starts_with(\".\") || h.starts_with(\"-\")) { return false }\n if (h.ends_with(\".\") || h.ends_with(\"-\")) { return false }\n var ok = true\n each (let c, h.to_char_arr()) {\n if (!HOST_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。\n/// 妥当でなければ null\n@normalizeHost(s) {\n if (Core:type(s) != \"str\") { return null }\n var h = s.trim().lower()\n if (h.starts_with(\"https://\")) { h = h.slice(8, h.len) }\n elif (h.starts_with(\"http://\")) { h = h.slice(7, h.len) }\n // @user@host 形式は最後の @ より後ろを取る\n let parts = h.split(\"@\")\n h = parts[parts.len - 1]\n let slash = h.index_of(\"/\")\n if (slash >= 0) { h = h.slice(0, slash) }\n if (h == \"\") { return null }\n if (!validHost(h)) { return null }\n h\n}\n\n/// href が調査対象ホスト自身の https URL か\n@ownHttpsUrl(href, host) {\n if (Core:type(href) != \"str\") { return false }\n href.lower().starts_with(`https://{host}/`)\n}\n\n/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ)\n@pickNodeinfoUrl(links, host) {\n if (Core:type(links) != \"arr\") { return null }\n var url20 = null\n var url21 = null\n each (let l, links) {\n let href = get(l, \"href\", null)\n if (ownHttpsUrl(href, host)) {\n let rel = get(l, \"rel\", \"\")\n if (rel == WELL_KNOWN_REL_21) { url21 = href }\n elif (rel == WELL_KNOWN_REL_20) { url20 = href }\n }\n }\n if (Core:type(url21) == \"str\") url21 else url20\n}\n\n/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙)\n@softwareColor(name) {\n if (name == \"misskey\" || name == \"sharkey\" || name == \"cherrypick\") { COL_OPEN }\n elif (name == \"firefish\" || name == \"iceshrimp\") { COL_OPEN }\n elif (name == \"mastodon\" || name == \"hometown\") { \"6364ff\" }\n elif (name == \"pleroma\" || name == \"akkoma\") { \"fba457\" }\n else { COL_SUB }\n}\n\n@softwareName(ni) {\n let name = get(get(ni, \"software\", null), \"name\", null)\n if (Core:type(name) != \"str\") \"\" else name.lower()\n}\n\n/// \"misskey v2025.4.1\" 形式のバッジ本文\n@softwareLabel(ni) {\n let sw = get(ni, \"software\", null)\n let name = get(sw, \"name\", null)\n if (Core:type(name) != \"str\") { return \"不明\" }\n let ver = get(sw, \"version\", null)\n if (Core:type(ver) != \"str\") { safeLabel(trunc(name, 24)) }\n else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` }\n}\n\n/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ\n@regLabel(ni) {\n if (get(ni, \"openRegistrations\", false) != true) {\n `$[fg.color={COL_SUB} 招待制]`\n } elif (get(get(ni, \"metadata\", null), \"emailRequiredForSignup\", false) == true) {\n `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_OPEN} 登録開放]`\n }\n}\n\n/// 表示行 (MFM 文字列) を組み立てる。常に 5 行\n@infoRows(ni, host) {\n let name = trunc(safeLabel(dflt(get(get(ni, \"metadata\", null), \"nodeName\", null), host)), 28)\n let users = get(get(get(ni, \"usage\", null), \"users\", null), \"total\", null)\n let posts = get(get(ni, \"usage\", null), \"localPosts\", null)\n let active = get(get(get(ni, \"usage\", null), \"users\", null), \"activeMonth\", null)\n var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**`\n if (Core:type(active) == \"num\") {\n stats = `{stats} 🔥 **{fmtNum(active)}**`\n }\n let protocols = get(ni, \"protocols\", null)\n var footer = `nodeinfo {dflt(get(ni, \"version\", null), \"?\")}`\n if (Core:type(protocols) == \"arr\" && protocols.len > 0) {\n footer = `{footer} ・ {safeLabel(trunc(protocols.join(\", \"), 40))}`\n }\n [\n `
🌐 [**{name}**](https://{host})
`\n `
$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]
`\n `
{stats}
`\n `
{regLabel(ni)}
`\n `
$[fg.color={COL_SUB} {footer}]
`\n ]\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように)\nvar host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST)\nvar input = host\nvar firstRun = true\nvar errorMsg = null\n\n/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる\n@fetchNodeinfo(h) {\n errorMsg = null\n let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (disc.status != 200) {\n errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})`\n return null\n }\n let url = pickNodeinfoUrl(get(parseObj(disc.body), \"links\", null), h)\n if (Core:type(url) != \"str\") {\n errorMsg = \"対応する nodeinfo (2.0 / 2.1) のリンクがありません\"\n return null\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) {\n errorMsg = `nodeinfo を取得できません (HTTP {res.status})`\n return null\n }\n let ni = parseObj(res.body)\n if (Core:type(ni) != \"obj\") {\n errorMsg = \"nodeinfo の応答を読めませんでした\"\n }\n ni\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるサーバー\"\n opened: false\n children: [\n Ui:C:textInput({\n default: input\n label: \"ホスト\"\n text: \"misskey.io / https://mastodon.social など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let h = normalizeHost(input)\n if (Core:type(h) != \"str\") {\n Mk:dialog(\"エラー\", \"ホスト名が不正です。misskey.io のような形式で入力してください\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が\n // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける\n host = h\n input = h\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{host} を調べています… 🌐` })]\n })\n ])\n }\n firstRun = false\n\n let ni = fetchNodeinfo(host)\n if (Core:type(ni) != \"obj\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `
$[fg.color={COL_SUB} {safeLabel(host)}]
` })\n Ui:C:mfm({ text: `
$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]
` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n\n Mk:save(HOST_KEY, host)\n\n var body = []\n each (let r, infoRows(ni, host)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// nodeinfo の内容はほぼ静的なので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"} diff --git a/public/registry/widgets/nodeinfo/icon.svg b/public/registry/widgets/nodeinfo/icon.svg new file mode 100644 index 0000000..191a7bb --- /dev/null +++ b/public/registry/widgets/nodeinfo/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/public/registry/widgets/nodeinfo/meta.json b/public/registry/widgets/nodeinfo/meta.json new file mode 100644 index 0000000..a3819a2 --- /dev/null +++ b/public/registry/widgets/nodeinfo/meta.json @@ -0,0 +1,14 @@ +{ + "id": "nodeinfo", + "name": "nodeinfo 調査", + "version": "1.0.0", + "author": "@hitalin", + "authorUrl": "https://github.com/hitalin", + "description": "任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・バージョン・規模・登録受付状態を表示。Misskey / Mastodon / Pleroma など ActivityPub 実装に共通で通り、認証不要", + "icon": "ti-scan", + "autoRun": true, + "category": "stats", + "capabilities": ["notedeck-api"], + "license": "MIT", + "tags": ["fediverse", "nodeinfo", "server", "stats", "misskey", "mastodon"] +} diff --git a/public/registry/widgets/nodeinfo/widget.is b/public/registry/widgets/nodeinfo/widget.is new file mode 100644 index 0000000..1af4379 --- /dev/null +++ b/public/registry/widgets/nodeinfo/widget.is @@ -0,0 +1,298 @@ +/// @ 1.2.1 +/// nodeinfo ウィジェット +/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。 +/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。 +/// +/// 実装メモ: +/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。 +/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない +/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す +/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、 +/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため) +/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。 +/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない +/// - 内容はほぼ静的なので自動更新は 6 時間おき + +let WELL_KNOWN_REL_20 = "http://nodeinfo.diaspora.software/ns/schema/2.0" +let WELL_KNOWN_REL_21 = "http://nodeinfo.diaspora.software/ns/schema/2.1" +let HOST_KEY = "nodeinfo:host" +let DEFAULT_HOST = "misskey.io" +let HOST_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789.-" +// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない +let MAX_BODY = 200000 + +// カラーパレット (ink-new-servers と同系統) +let COL_OPEN = "86b300" // 登録開放 +let COL_DOWN = "f74e52" // エラー +let COL_SUB = "6b7480" // 補助・招待制 + +// ───────────────────────────── +// ヘルパー +// ───────────────────────────── +@dflt(v, d) { if (Core:type(v) == "null") d else v } + +@get(o, k, d) { + if (Core:type(o) != "obj") { d } + elif (Obj:has(o, k)) { dflt(o[k], d) } + else { d } +} + +/// レスポンス本文を JSON オブジェクトとして読む。 +/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す +@parseObj(body) { + if (Core:type(body) != "str") { return null } + if (body.len > MAX_BODY) { return null } + let v = Json:parse(body) + if (Core:type(v) == "obj") v else null +} + +/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億) +@fmtNum(n) { + if (Core:type(n) != "num") { "?" } + elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` } + elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` } + else { `{Math:round(n)}` } +} + +/// 相手サーバー由来の文字列を表示用に無害化する。 +/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない +@safeLabel(s) { + if (Core:type(s) != "str") { "?" } + else { + s.replace("$", "$").replace("[", "[").replace("]", "]").replace("*", "*").replace("<", "<").replace(">", ">") + } +} + +@trunc(s, n) { + if (s.len > n) `{s.slice(0, n)}…` else s +} + +/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す +/// (ポート付き・IDN・localhost は弾く) +@validHost(h) { + if (!h.incl(".")) { return false } + if (h.starts_with(".") || h.starts_with("-")) { return false } + if (h.ends_with(".") || h.ends_with("-")) { return false } + var ok = true + each (let c, h.to_char_arr()) { + if (!HOST_CHARS.incl(c)) { ok = false } + } + ok +} + +/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。 +/// 妥当でなければ null +@normalizeHost(s) { + if (Core:type(s) != "str") { return null } + var h = s.trim().lower() + if (h.starts_with("https://")) { h = h.slice(8, h.len) } + elif (h.starts_with("http://")) { h = h.slice(7, h.len) } + // @user@host 形式は最後の @ より後ろを取る + let parts = h.split("@") + h = parts[parts.len - 1] + let slash = h.index_of("/") + if (slash >= 0) { h = h.slice(0, slash) } + if (h == "") { return null } + if (!validHost(h)) { return null } + h +} + +/// href が調査対象ホスト自身の https URL か +@ownHttpsUrl(href, host) { + if (Core:type(href) != "str") { return false } + href.lower().starts_with(`https://{host}/`) +} + +/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ) +@pickNodeinfoUrl(links, host) { + if (Core:type(links) != "arr") { return null } + var url20 = null + var url21 = null + each (let l, links) { + let href = get(l, "href", null) + if (ownHttpsUrl(href, host)) { + let rel = get(l, "rel", "") + if (rel == WELL_KNOWN_REL_21) { url21 = href } + elif (rel == WELL_KNOWN_REL_20) { url20 = href } + } + } + if (Core:type(url21) == "str") url21 else url20 +} + +/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙) +@softwareColor(name) { + if (name == "misskey" || name == "sharkey" || name == "cherrypick") { COL_OPEN } + elif (name == "firefish" || name == "iceshrimp") { COL_OPEN } + elif (name == "mastodon" || name == "hometown") { "6364ff" } + elif (name == "pleroma" || name == "akkoma") { "fba457" } + else { COL_SUB } +} + +@softwareName(ni) { + let name = get(get(ni, "software", null), "name", null) + if (Core:type(name) != "str") "" else name.lower() +} + +/// "misskey v2025.4.1" 形式のバッジ本文 +@softwareLabel(ni) { + let sw = get(ni, "software", null) + let name = get(sw, "name", null) + if (Core:type(name) != "str") { return "不明" } + let ver = get(sw, "version", null) + if (Core:type(ver) != "str") { safeLabel(trunc(name, 24)) } + else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` } +} + +/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ +@regLabel(ni) { + if (get(ni, "openRegistrations", false) != true) { + `$[fg.color={COL_SUB} 招待制]` + } elif (get(get(ni, "metadata", null), "emailRequiredForSignup", false) == true) { + `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]` + } else { + `$[fg.color={COL_OPEN} 登録開放]` + } +} + +/// 表示行 (MFM 文字列) を組み立てる。常に 5 行 +@infoRows(ni, host) { + let name = trunc(safeLabel(dflt(get(get(ni, "metadata", null), "nodeName", null), host)), 28) + let users = get(get(get(ni, "usage", null), "users", null), "total", null) + let posts = get(get(ni, "usage", null), "localPosts", null) + let active = get(get(get(ni, "usage", null), "users", null), "activeMonth", null) + var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**` + if (Core:type(active) == "num") { + stats = `{stats} 🔥 **{fmtNum(active)}**` + } + let protocols = get(ni, "protocols", null) + var footer = `nodeinfo {dflt(get(ni, "version", null), "?")}` + if (Core:type(protocols) == "arr" && protocols.len > 0) { + footer = `{footer} ・ {safeLabel(trunc(protocols.join(", "), 40))}` + } + [ + `
🌐 [**{name}**](https://{host})
` + `
$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]
` + `
{stats}
` + `
{regLabel(ni)}
` + `
$[fg.color={COL_SUB} {footer}]
` + ] +} + +// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ---- + +// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように) +var host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST) +var input = host +var firstRun = true +var errorMsg = null + +/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる +@fetchNodeinfo(h) { + errorMsg = null + let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, { + method: "GET" + headers: { "Accept": "application/json" } + }) + if (disc.status != 200) { + errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})` + return null + } + let url = pickNodeinfoUrl(get(parseObj(disc.body), "links", null), h) + if (Core:type(url) != "str") { + errorMsg = "対応する nodeinfo (2.0 / 2.1) のリンクがありません" + return null + } + let res = Nd:http(url, { + method: "GET" + headers: { "Accept": "application/json" } + }) + if (res.status != 200) { + errorMsg = `nodeinfo を取得できません (HTTP {res.status})` + return null + } + let ni = parseObj(res.body) + if (Core:type(ni) != "obj") { + errorMsg = "nodeinfo の応答を読めませんでした" + } + ni +} + +@configFolder() { + Ui:C:folder({ + title: "⚙️ 調べるサーバー" + opened: false + children: [ + Ui:C:textInput({ + default: input + label: "ホスト" + text: "misskey.io / https://mastodon.social など" + onInput: @(v) { input = v } + }) + Ui:C:button({ + text: "🔍 調べる" + rounded: true + primary: true + onClick: @() { + let h = normalizeHost(input) + if (Core:type(h) != "str") { + Mk:dialog("エラー", "ホスト名が不正です。misskey.io のような形式で入力してください", "error") + } else { + // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が + // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける + host = h + input = h + firstRun = true + render() + } + } + }) + ] + }) +} + +@render() { + if (firstRun) { + Ui:render([ + Ui:C:container({ + align: "center" + children: [Ui:C:text({ text: `{host} を調べています… 🌐` })] + }) + ]) + } + firstRun = false + + let ni = fetchNodeinfo(host) + if (Core:type(ni) != "obj") { + Ui:render([ + Ui:C:container({ + align: "center" + children: [ + Ui:C:mfm({ text: `
$[fg.color={COL_SUB} {safeLabel(host)}]
` }) + Ui:C:mfm({ text: `
$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, "取得に失敗しました")}]
` }) + Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { render() } }) + configFolder() + ] + }) + ]) + return null + } + + Mk:save(HOST_KEY, host) + + var body = [] + each (let r, infoRows(ni, host)) { + body.push(Ui:C:mfm({ text: r })) + } + body.push(Ui:C:button({ text: "🔄 更新", rounded: true, onClick: @() { render() } })) + body.push(configFolder()) + Ui:render([ + Ui:C:container({ + align: "center" + children: body + }) + ]) +} + +render() +// nodeinfo の内容はほぼ静的なので 6 時間おきで十分 +Async:interval(21600000, render, false) diff --git a/public/registry/widgets/osv-vulns/api.json b/public/registry/widgets/osv-vulns/api.json new file mode 100644 index 0000000..3a62846 --- /dev/null +++ b/public/registry/widgets/osv-vulns/api.json @@ -0,0 +1 @@ +{"type":"widget","data":"/// @ 1.2.1\n/// OSV 脆弱性ウィジェット\n/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、\n/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。\n/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。\n///\n/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、\n/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。\n///\n/// 実装メモ:\n/// - POST https://api.osv.dev/v1/query に {\"package\":{\"name\",\"ecosystem\"}} を送る。\n/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。\n/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す\n/// - ecosystem の綴りは OSV 側で厳密 (\"PyPI\" / \"crates.io\" / \"RubyGems\")。select で固定する\n/// - 0 件のときの応答は {} (vulns キーごと無い)\n/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ\n/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える\n\nlet API_URL = \"https://api.osv.dev/v1/query\"\nlet PKG_KEY = \"osv-vulns:pkg\"\nlet ECO_KEY = \"osv-vulns:eco\"\nlet DEFAULT_PKG = \"misskey-js\"\nlet DEFAULT_ECO = \"npm\"\nlet MAX_ITEMS = 5 // 表示する最大件数\nlet DESC_LEN = 58 // 概要の最大表示文字数\nlet MAX_NAME = 128 // パッケージ名の最大長\n// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。\n// 桁違いのものはパースせず捨てる\nlet MAX_BODY = 1200000\n// パッケージ名に許す文字。\" と \\ を除くことで POST 本文を捏造されないようにする\nlet PKG_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+\"\n\n// cve ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\nlet COL_OK = \"16c784\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// OSV 由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// パッケージ名として妥当か (許可文字のみ・長さ制限)\n@validPkg(s) {\n if (Core:type(s) != \"str\") { return false }\n if (s == \"\" || s.len > MAX_NAME) { return false }\n var ok = true\n each (let c, s.to_char_arr()) {\n if (!PKG_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値をパッケージ名に整える。妥当でなければ null\n@normalizePkg(s) {\n if (Core:type(s) != \"str\") { return null }\n let p = s.trim()\n if (!validPkg(p)) { return null }\n p\n}\n\n/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ \"不明\"\n@sevOf(v) {\n let s = get(get(v, \"database_specific\", null), \"severity\", null)\n if (Core:type(s) != \"str\" || s == \"\") \"不明\" else s.upper()\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MODERATE\" || sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { COL_OK }\n else { COL_FLAT }\n}\n\n/// ISO 8601 から \"MM/DD\" を取り出す\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) \"\" else `{s.slice(5, 7)}/{s.slice(8, 10)}`\n}\n\n/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。\n/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。\n/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる\n@pubKey(v) {\n let s = get(v, \"published\", null)\n if (Core:type(s) != \"str\" || s.len < 19) { return 0 }\n let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}`\n dflt(digits.to_num(), 0)\n}\n\n/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ \"\"\n@fixedVersion(v) {\n var found = \"\"\n each (let a, get(v, \"affected\", [])) {\n each (let r, get(a, \"ranges\", [])) {\n each (let e, get(r, \"events\", [])) {\n let f = get(e, \"fixed\", null)\n if (found == \"\" && Core:type(f) == \"str\") { found = f }\n }\n }\n }\n found\n}\n\n/// 脆弱性 1 件 = 2 行カード\n/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度\n/// 行2:  MM/DD 概要 → 修正バージョン\n@vulnRows(v) {\n let id = safeLabel(trunc(dflt(get(v, \"id\", null), \"?\"), 40))\n let sev = sevOf(v)\n let col = sevColor(sev)\n let summary = trunc(safeLabel(dflt(get(v, \"summary\", null), \"(概要なし)\")), DESC_LEN)\n var l2 = ` $[fg.color={COL_DESC} {summary}]`\n let md = monthDay(get(v, \"published\", null))\n if (md != \"\") {\n l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]`\n }\n let fixed = fixedVersion(v)\n if (fixed != \"\") {\n l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]`\n }\n [\n `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]`\n l2\n ]\n}\n\n/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。\n/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと\n/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる\n@buildRows(vulns, max) {\n if (Core:type(vulns) != \"arr\") { return [] }\n var keyed = []\n each (let v, vulns) {\n keyed.push({ k: pubKey(v), v: v })\n }\n let sorted = keyed.sort(@(a, b) { b.k - a.k })\n var rows = []\n var shown = 0\n each (let e, sorted) {\n if (shown < max) {\n rows = rows.concat(vulnRows(e.v))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// OSV の ecosystem 名は綴りが厳密なので select で固定する\nlet ECOSYSTEMS = [\n { text: \"npm\", value: \"npm\" }\n { text: \"PyPI\", value: \"PyPI\" }\n { text: \"crates.io\", value: \"crates.io\" }\n { text: \"Go\", value: \"Go\" }\n { text: \"RubyGems\", value: \"RubyGems\" }\n { text: \"Packagist\", value: \"Packagist\" }\n { text: \"Maven\", value: \"Maven\" }\n { text: \"NuGet\", value: \"NuGet\" }\n]\n\n// 保存値も信用せず normalizePkg に通す\nvar pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG)\nvar eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO)\nvar input = pkg\nvar firstRun = true\nvar errorMsg = null\n\n/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる\n@fetchVulns(name, ecosystem) {\n errorMsg = null\n let res = Nd:http(API_URL, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({\n package: {\n name: name\n ecosystem: ecosystem\n }\n })\n })\n if (res.status != 200) {\n errorMsg = `OSV に問い合わせできません (HTTP {res.status})`\n return null\n }\n let data = parseObj(res.body)\n if (Core:type(data) != \"obj\") {\n errorMsg = \"OSV の応答を読めませんでした\"\n return null\n }\n // 0 件のときは {} が返る (vulns キーごと無い)\n get(data, \"vulns\", [])\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるパッケージ\"\n opened: false\n children: [\n Ui:C:select({\n items: ECOSYSTEMS\n default: eco\n onChange: @(v) {\n eco = v\n Mk:save(ECO_KEY, v)\n }\n })\n Ui:C:textInput({\n default: input\n label: \"パッケージ名\"\n text: \"misskey-js / @misskey-dev/note など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let p = normalizePkg(input)\n if (Core:type(p) != \"str\") {\n Mk:dialog(\"エラー\", \"パッケージ名に使えない文字が含まれています\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)\n pkg = p\n input = p\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@header() {\n Ui:C:mfm({ text: `
🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]
` })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })]\n })\n ])\n }\n firstRun = false\n\n let vulns = fetchVulns(pkg, eco)\n if (Core:type(vulns) != \"arr\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n header()\n Ui:C:mfm({ text: `
$[fg.color=ea3943 ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]
` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n Mk:save(PKG_KEY, pkg)\n\n var body = [header()]\n if (vulns.len == 0) {\n body.push(Ui:C:mfm({ text: `
$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]
` }))\n } else {\n each (let r, buildRows(vulns, MAX_ITEMS)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n if (vulns.len > MAX_ITEMS) {\n body.push(Ui:C:mfm({ text: `
$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]
` }))\n }\n }\n body.push(Ui:C:mfm({ text: `
$[fg.color={COL_FLAT} データ: osv.dev]
` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// アドバイザリの発行は日次より粗いので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"} diff --git a/public/registry/widgets/osv-vulns/icon.svg b/public/registry/widgets/osv-vulns/icon.svg new file mode 100644 index 0000000..e9564c0 --- /dev/null +++ b/public/registry/widgets/osv-vulns/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/public/registry/widgets/osv-vulns/meta.json b/public/registry/widgets/osv-vulns/meta.json new file mode 100644 index 0000000..88c3dc4 --- /dev/null +++ b/public/registry/widgets/osv-vulns/meta.json @@ -0,0 +1,14 @@ +{ + "id": "osv-vulns", + "name": "OSV 脆弱性", + "version": "1.0.0", + "author": "@hitalin", + "authorUrl": "https://github.com/hitalin", + "description": "OSV.dev にパッケージ名を投げて、そのパッケージの既知脆弱性を重大度・修正バージョン付きで新しい順に表示。既定は npm:misskey-js。npm / PyPI / crates.io など 8 エコシステム対応、API キー不要", + "icon": "ti-package-search", + "autoRun": true, + "category": "display", + "capabilities": ["notedeck-api"], + "license": "MIT", + "tags": ["security", "vulnerability", "osv", "cve", "supply-chain", "misskey"] +} diff --git a/public/registry/widgets/osv-vulns/widget.is b/public/registry/widgets/osv-vulns/widget.is new file mode 100644 index 0000000..dc08007 --- /dev/null +++ b/public/registry/widgets/osv-vulns/widget.is @@ -0,0 +1,324 @@ +/// @ 1.2.1 +/// OSV 脆弱性ウィジェット +/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、 +/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。 +/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。 +/// +/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、 +/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。 +/// +/// 実装メモ: +/// - POST https://api.osv.dev/v1/query に {"package":{"name","ecosystem"}} を送る。 +/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。 +/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す +/// - ecosystem の綴りは OSV 側で厳密 ("PyPI" / "crates.io" / "RubyGems")。select で固定する +/// - 0 件のときの応答は {} (vulns キーごと無い) +/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ +/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える + +let API_URL = "https://api.osv.dev/v1/query" +let PKG_KEY = "osv-vulns:pkg" +let ECO_KEY = "osv-vulns:eco" +let DEFAULT_PKG = "misskey-js" +let DEFAULT_ECO = "npm" +let MAX_ITEMS = 5 // 表示する最大件数 +let DESC_LEN = 58 // 概要の最大表示文字数 +let MAX_NAME = 128 // パッケージ名の最大長 +// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。 +// 桁違いのものはパースせず捨てる +let MAX_BODY = 1200000 +// パッケージ名に許す文字。" と \ を除くことで POST 本文を捏造されないようにする +let PKG_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+" + +// cve ウィジェットと揃えたカラー +let COL_DIM = "5b6470" +let COL_FLAT = "8a919e" +let COL_DESC = "b0b7c3" +let COL_OK = "16c784" + +// ───────────────────────────── +// ヘルパー +// ───────────────────────────── +@dflt(v, d) { if (Core:type(v) == "null") d else v } + +@get(o, k, d) { + if (Core:type(o) != "obj") { d } + elif (Obj:has(o, k)) { dflt(o[k], d) } + else { d } +} + +/// レスポンス本文を JSON オブジェクトとして読む。 +/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す +@parseObj(body) { + if (Core:type(body) != "str") { return null } + if (body.len > MAX_BODY) { return null } + let v = Json:parse(body) + if (Core:type(v) == "obj") v else null +} + +/// OSV 由来の文字列を表示用に無害化する。 +/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない +@safeLabel(s) { + if (Core:type(s) != "str") { "?" } + else { + s.replace("$", "$").replace("[", "[").replace("]", "]").replace("*", "*").replace("<", "<").replace(">", ">") + } +} + +@trunc(s, n) { + if (s.len > n) `{s.slice(0, n)}…` else s +} + +/// パッケージ名として妥当か (許可文字のみ・長さ制限) +@validPkg(s) { + if (Core:type(s) != "str") { return false } + if (s == "" || s.len > MAX_NAME) { return false } + var ok = true + each (let c, s.to_char_arr()) { + if (!PKG_CHARS.incl(c)) { ok = false } + } + ok +} + +/// 入力欄の値をパッケージ名に整える。妥当でなければ null +@normalizePkg(s) { + if (Core:type(s) != "str") { return null } + let p = s.trim() + if (!validPkg(p)) { return null } + p +} + +/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ "不明" +@sevOf(v) { + let s = get(get(v, "database_specific", null), "severity", null) + if (Core:type(s) != "str" || s == "") "不明" else s.upper() +} + +@sevColor(sev) { + if (sev == "CRITICAL") { "ea3943" } + elif (sev == "HIGH") { "ff8c42" } + elif (sev == "MODERATE" || sev == "MEDIUM") { "f5a623" } + elif (sev == "LOW") { COL_OK } + else { COL_FLAT } +} + +/// ISO 8601 から "MM/DD" を取り出す +@monthDay(s) { + if (Core:type(s) != "str" || s.len < 10) "" else `{s.slice(5, 7)}/{s.slice(8, 10)}` +} + +/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。 +/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。 +/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる +@pubKey(v) { + let s = get(v, "published", null) + if (Core:type(s) != "str" || s.len < 19) { return 0 } + let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}` + dflt(digits.to_num(), 0) +} + +/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ "" +@fixedVersion(v) { + var found = "" + each (let a, get(v, "affected", [])) { + each (let r, get(a, "ranges", [])) { + each (let e, get(r, "events", [])) { + let f = get(e, "fixed", null) + if (found == "" && Core:type(f) == "str") { found = f } + } + } + } + found +} + +/// 脆弱性 1 件 = 2 行カード +/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度 +/// 行2:  MM/DD 概要 → 修正バージョン +@vulnRows(v) { + let id = safeLabel(trunc(dflt(get(v, "id", null), "?"), 40)) + let sev = sevOf(v) + let col = sevColor(sev) + let summary = trunc(safeLabel(dflt(get(v, "summary", null), "(概要なし)")), DESC_LEN) + var l2 = ` $[fg.color={COL_DESC} {summary}]` + let md = monthDay(get(v, "published", null)) + if (md != "") { + l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]` + } + let fixed = fixedVersion(v) + if (fixed != "") { + l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]` + } + [ + `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]` + l2 + ] +} + +/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。 +/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと +/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる +@buildRows(vulns, max) { + if (Core:type(vulns) != "arr") { return [] } + var keyed = [] + each (let v, vulns) { + keyed.push({ k: pubKey(v), v: v }) + } + let sorted = keyed.sort(@(a, b) { b.k - a.k }) + var rows = [] + var shown = 0 + each (let e, sorted) { + if (shown < max) { + rows = rows.concat(vulnRows(e.v)) + shown += 1 + } + } + rows +} + +// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ---- + +// OSV の ecosystem 名は綴りが厳密なので select で固定する +let ECOSYSTEMS = [ + { text: "npm", value: "npm" } + { text: "PyPI", value: "PyPI" } + { text: "crates.io", value: "crates.io" } + { text: "Go", value: "Go" } + { text: "RubyGems", value: "RubyGems" } + { text: "Packagist", value: "Packagist" } + { text: "Maven", value: "Maven" } + { text: "NuGet", value: "NuGet" } +] + +// 保存値も信用せず normalizePkg に通す +var pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG) +var eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO) +var input = pkg +var firstRun = true +var errorMsg = null + +/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる +@fetchVulns(name, ecosystem) { + errorMsg = null + let res = Nd:http(API_URL, { + method: "POST" + headers: { + "Content-Type": "application/json" + "Accept": "application/json" + } + body: Json:stringify({ + package: { + name: name + ecosystem: ecosystem + } + }) + }) + if (res.status != 200) { + errorMsg = `OSV に問い合わせできません (HTTP {res.status})` + return null + } + let data = parseObj(res.body) + if (Core:type(data) != "obj") { + errorMsg = "OSV の応答を読めませんでした" + return null + } + // 0 件のときは {} が返る (vulns キーごと無い) + get(data, "vulns", []) +} + +@configFolder() { + Ui:C:folder({ + title: "⚙️ 調べるパッケージ" + opened: false + children: [ + Ui:C:select({ + items: ECOSYSTEMS + default: eco + onChange: @(v) { + eco = v + Mk:save(ECO_KEY, v) + } + }) + Ui:C:textInput({ + default: input + label: "パッケージ名" + text: "misskey-js / @misskey-dev/note など" + onInput: @(v) { input = v } + }) + Ui:C:button({ + text: "🔍 調べる" + rounded: true + primary: true + onClick: @() { + let p = normalizePkg(input) + if (Core:type(p) != "str") { + Mk:dialog("エラー", "パッケージ名に使えない文字が含まれています", "error") + } else { + // 保存は取得成功後 (render 内) + pkg = p + input = p + firstRun = true + render() + } + } + }) + ] + }) +} + +@header() { + Ui:C:mfm({ text: `
🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]
` }) +} + +@render() { + if (firstRun) { + Ui:render([ + Ui:C:container({ + align: "center" + children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })] + }) + ]) + } + firstRun = false + + let vulns = fetchVulns(pkg, eco) + if (Core:type(vulns) != "arr") { + Ui:render([ + Ui:C:container({ + align: "center" + children: [ + header() + Ui:C:mfm({ text: `
$[fg.color=ea3943 ⚠️ {dflt(errorMsg, "取得に失敗しました")}]
` }) + Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { render() } }) + configFolder() + ] + }) + ]) + return null + } + Mk:save(PKG_KEY, pkg) + + var body = [header()] + if (vulns.len == 0) { + body.push(Ui:C:mfm({ text: `
$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]
` })) + } else { + each (let r, buildRows(vulns, MAX_ITEMS)) { + body.push(Ui:C:mfm({ text: r })) + } + if (vulns.len > MAX_ITEMS) { + body.push(Ui:C:mfm({ text: `
$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]
` })) + } + } + body.push(Ui:C:mfm({ text: `
$[fg.color={COL_FLAT} データ: osv.dev]
` })) + body.push(Ui:C:button({ text: "🔄 更新", rounded: true, onClick: @() { render() } })) + body.push(configFolder()) + Ui:render([ + Ui:C:container({ + align: "center" + children: body + }) + ]) +} + +render() +// アドバイザリの発行は日次より粗いので 6 時間おきで十分 +Async:interval(21600000, render, false) diff --git a/scripts/sync-icons.js b/scripts/sync-icons.js index 337e398..60710ff 100644 --- a/scripts/sync-icons.js +++ b/scripts/sync-icons.js @@ -47,7 +47,9 @@ const ICON_MAP = { 'widgets/mewk': { pack: 'lucide', name: 'cat' }, 'widgets/minecraft': { pack: 'lucide', name: 'box' }, 'widgets/misskey-online': { pack: 'simple', name: 'misskey' }, + 'widgets/nodeinfo': { pack: 'lucide', name: 'scan-search' }, 'widgets/okj-prices': { pack: 'lucide', name: 'chart-candlestick' }, + 'widgets/osv-vulns': { pack: 'lucide', name: 'package-search' }, 'widgets/onedraw-timer': { pack: 'lucide', name: 'timer' }, // 本家 misskey-tools のミス廃アラートは fa-tower-broadcast 'widgets/misuhai-score': { pack: 'lucide', name: 'radio-tower' },