opencode-ufr 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/ufr.ts +4 -0
- package/models.json +206 -0
- package/package.json +16 -0
- package/src/cli/catalog.ts +45 -0
- package/src/cli/connect.ts +146 -0
- package/src/cli/daemon-client.ts +19 -0
- package/src/cli/index.ts +93 -0
- package/src/cli/io.ts +98 -0
- package/src/cli/keys.ts +95 -0
- package/src/cli/stats.ts +37 -0
- package/src/cli/status.ts +48 -0
- package/src/cli/stop.ts +8 -0
- package/src/cli/ufr-check.ts +43 -0
- package/src/daemon/breaker.ts +146 -0
- package/src/daemon/catalog-source.ts +85 -0
- package/src/daemon/catalog.ts +127 -0
- package/src/daemon/daemon.ts +397 -0
- package/src/daemon/fallbacks.ts +41 -0
- package/src/daemon/keypool.ts +109 -0
- package/src/daemon/log.ts +15 -0
- package/src/daemon/main.ts +18 -0
- package/src/daemon/model.ts +15 -0
- package/src/daemon/router.ts +392 -0
- package/src/daemon/server.ts +54 -0
- package/src/daemon/stats.ts +105 -0
- package/src/daemon/transport.ts +21 -0
- package/src/daemon/upstream.ts +68 -0
- package/src/daemon/vpn/fortinet.ts +466 -0
- package/src/daemon/vpn/ip.ts +176 -0
- package/src/daemon/vpn/manager.ts +258 -0
- package/src/daemon/vpn/ppp.ts +450 -0
- package/src/daemon/vpn/proxy.ts +102 -0
- package/src/daemon/vpn/stack.ts +140 -0
- package/src/daemon/vpn/tcp.ts +356 -0
- package/src/daemon/window.ts +126 -0
- package/src/plugin/connect.ts +113 -0
- package/src/plugin/ensure-daemon.ts +101 -0
- package/src/plugin/index.ts +93 -0
- package/src/plugin/model-info.ts +35 -0
- package/src/shared/config.ts +170 -0
- package/src/shared/connect.ts +100 -0
- package/src/shared/errors.ts +6 -0
- package/src/shared/fs.ts +31 -0
- package/src/shared/models-file.ts +68 -0
- package/src/shared/paths.ts +62 -0
- package/src/shared/secrets.ts +62 -0
- package/src/shared/sleep.ts +15 -0
- package/src/shared/time.ts +6 -0
- package/src/shared/version.ts +3 -0
- package/src/shared/vpn.ts +8 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connect logic: parsing key lists and applying credentials to the
|
|
3
|
+
* keyring + config. Used by the CLI (`ufr connect`) and by the plugin's
|
|
4
|
+
* /connect integration watcher.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { loadConfig, saveConfig } from "./config"
|
|
8
|
+
import type { Paths } from "./paths"
|
|
9
|
+
import type { SecretStore } from "./secrets"
|
|
10
|
+
import { VPN_PASS, VPN_USER } from "./secrets"
|
|
11
|
+
|
|
12
|
+
/** Splits a key list on commas AND newlines, trims whitespace, drops empties. */
|
|
13
|
+
export function splitKeys(spec: string): string[] {
|
|
14
|
+
return spec
|
|
15
|
+
.split(/[,\n\r]+/)
|
|
16
|
+
.map((k) => k.trim())
|
|
17
|
+
.filter((k) => k.length > 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Auto-aliases key1…keyN, skipping numbers already in use. */
|
|
21
|
+
export function nextAliases(existing: string[], count: number): string[] {
|
|
22
|
+
const used = new Set(existing)
|
|
23
|
+
const out: string[] = []
|
|
24
|
+
for (let n = 1; out.length < count; n++) {
|
|
25
|
+
const alias = `key${n}`
|
|
26
|
+
if (!used.has(alias)) {
|
|
27
|
+
used.add(alias)
|
|
28
|
+
out.push(alias)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const EMAIL_RE = /^[^\s@]+@[^\s@]+$/
|
|
35
|
+
|
|
36
|
+
export type ConnectInput = {
|
|
37
|
+
keys: string // comma/newline separated, whitespace filtered
|
|
38
|
+
login?: string // optional uni login (built-in VPN)
|
|
39
|
+
password?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ConnectApplied = {
|
|
43
|
+
aliases: { alias: string; key: string }[]
|
|
44
|
+
removedAliases: string[]
|
|
45
|
+
loginStored: boolean
|
|
46
|
+
errors: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Applies a /connect submission: stores the keys as key1…keyN in the keyring
|
|
51
|
+
* (replacing previous key* aliases), optionally the uni login, and updates
|
|
52
|
+
* config.keys. Custom (non-key*) aliases are left untouched.
|
|
53
|
+
*/
|
|
54
|
+
export async function applyConnect(
|
|
55
|
+
input: ConnectInput,
|
|
56
|
+
o: { paths: Paths; secrets: SecretStore; log?: (m: string) => void },
|
|
57
|
+
): Promise<ConnectApplied> {
|
|
58
|
+
const log = o.log ?? (() => {})
|
|
59
|
+
const errors: string[] = []
|
|
60
|
+
const keys = splitKeys(input.keys)
|
|
61
|
+
if (keys.length === 0) {
|
|
62
|
+
errors.push("no keys given")
|
|
63
|
+
return { aliases: [], removedAliases: [], loginStored: false, errors }
|
|
64
|
+
}
|
|
65
|
+
if (input.login && !EMAIL_RE.test(input.login.trim())) {
|
|
66
|
+
errors.push(`"${input.login}" does not look like a uni login`)
|
|
67
|
+
return { aliases: [], removedAliases: [], loginStored: false, errors }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cfg = await loadConfig(o.paths.configFile)
|
|
71
|
+
const custom = cfg.keys.filter((k) => !/^key\d+$/.test(k))
|
|
72
|
+
|
|
73
|
+
// replace the managed key1…keyN aliases
|
|
74
|
+
const removedAliases: string[] = []
|
|
75
|
+
const wanted = nextAliases(custom, keys.length)
|
|
76
|
+
for (const alias of cfg.keys) {
|
|
77
|
+
if (/^key\d+$/.test(alias) && !wanted.includes(alias)) {
|
|
78
|
+
await o.secrets.delete(alias)
|
|
79
|
+
removedAliases.push(alias)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const aliases: { alias: string; key: string }[] = []
|
|
83
|
+
for (let i = 0; i < keys.length; i++) {
|
|
84
|
+
const alias = wanted[i]!
|
|
85
|
+
await o.secrets.set(alias, keys[i]!)
|
|
86
|
+
aliases.push({ alias, key: keys[i]! })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let loginStored = false
|
|
90
|
+
if (input.login?.trim()) {
|
|
91
|
+
await o.secrets.set(VPN_USER, input.login.trim())
|
|
92
|
+
if (input.password) await o.secrets.set(VPN_PASS, input.password)
|
|
93
|
+
loginStored = true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
cfg.keys = [...custom, ...wanted]
|
|
97
|
+
await saveConfig(o.paths.configFile, cfg)
|
|
98
|
+
log(`connect applied: ${aliases.length} key(s), login ${loginStored ? "stored" : "unchanged"}`)
|
|
99
|
+
return { aliases, removedAliases, loginStored, errors }
|
|
100
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** OpenAI-format error with a whole-second Retry-After when a wait is known. */
|
|
2
|
+
export function errorResponse(status: number, type: string, message: string, retryAfterMs?: number): Response {
|
|
3
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
4
|
+
if (retryAfterMs !== undefined && retryAfterMs > 0) headers["retry-after"] = String(Math.max(1, Math.ceil(retryAfterMs / 1000)))
|
|
5
|
+
return new Response(JSON.stringify({ error: { message, type, code: status } }), { status, headers })
|
|
6
|
+
}
|
package/src/shared/fs.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Write via a temp file + rename, so readers never see half a file. */
|
|
5
|
+
export async function writeFileAtomic(file: string, data: string, mode?: number): Promise<void> {
|
|
6
|
+
await mkdir(dirname(file), { recursive: true })
|
|
7
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
8
|
+
await writeFile(tmp, data, mode === undefined ? {} : { mode })
|
|
9
|
+
await rename(tmp, file)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** File contents, or null if it does not exist. Other errors propagate. */
|
|
13
|
+
export async function readText(file: string): Promise<string | null> {
|
|
14
|
+
try {
|
|
15
|
+
return await readFile(file, "utf8")
|
|
16
|
+
} catch (e) {
|
|
17
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null
|
|
18
|
+
throw e
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed JSON, or null if the file is missing or not JSON. */
|
|
23
|
+
export async function readJson(file: string): Promise<unknown | null> {
|
|
24
|
+
const text = await readText(file)
|
|
25
|
+
if (text === null) return null
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text)
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** USD per 1M tokens. */
|
|
2
|
+
export type Price = { input: number; output: number; cache_read?: number; cache_write?: number }
|
|
3
|
+
|
|
4
|
+
export type ModelEntry = {
|
|
5
|
+
context?: number
|
|
6
|
+
max_output?: number
|
|
7
|
+
vision?: boolean
|
|
8
|
+
tools?: boolean
|
|
9
|
+
price?: Price
|
|
10
|
+
note?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ModelsFile = {
|
|
14
|
+
schema: 1
|
|
15
|
+
updated: string
|
|
16
|
+
defaults: { context: number; max_output: number }
|
|
17
|
+
fallbacks: { free_escape_order: string[]; max_targets: number; context_hub: string | null }
|
|
18
|
+
exclude: string[]
|
|
19
|
+
aliases: Record<string, string>
|
|
20
|
+
models: Record<string, ModelEntry>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ModelsFileError extends Error {}
|
|
24
|
+
|
|
25
|
+
const isObj = (x: unknown): x is Record<string, unknown> => typeof x === "object" && x !== null && !Array.isArray(x)
|
|
26
|
+
const posInt = (x: unknown) => typeof x === "number" && Number.isInteger(x) && x > 0
|
|
27
|
+
const nonNeg = (x: unknown) => typeof x === "number" && Number.isFinite(x) && x >= 0
|
|
28
|
+
const strList = (x: unknown) => Array.isArray(x) && x.every((s) => typeof s === "string")
|
|
29
|
+
|
|
30
|
+
function fail(path: string, why: string): never {
|
|
31
|
+
throw new ModelsFileError(`models.json: ${path} ${why}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateModelsFile(x: unknown): ModelsFile {
|
|
35
|
+
if (!isObj(x)) fail("(root)", "must be an object")
|
|
36
|
+
if (x.schema !== 1) fail("schema", `must be 1 (got ${JSON.stringify(x.schema)})`)
|
|
37
|
+
if (typeof x.updated !== "string") fail("updated", "must be a date string")
|
|
38
|
+
const d = x.defaults
|
|
39
|
+
if (!isObj(d) || !posInt(d.context) || !posInt(d.max_output)) fail("defaults", "needs positive integers context and max_output")
|
|
40
|
+
const fb = x.fallbacks
|
|
41
|
+
if (!isObj(fb)) fail("fallbacks", "must be an object")
|
|
42
|
+
if (!strList(fb.free_escape_order)) fail("fallbacks.free_escape_order", "must be a list of model ids")
|
|
43
|
+
if (!posInt(fb.max_targets)) fail("fallbacks.max_targets", "must be a positive integer")
|
|
44
|
+
if (fb.context_hub !== null && typeof fb.context_hub !== "string") fail("fallbacks.context_hub", "must be a model id or null")
|
|
45
|
+
if (!strList(x.exclude)) fail("exclude", "must be a list of model ids")
|
|
46
|
+
if (!isObj(x.aliases) || !Object.values(x.aliases).every((s) => typeof s === "string")) fail("aliases", "must map alias -> model id")
|
|
47
|
+
if (!isObj(x.models)) fail("models", "must be an object")
|
|
48
|
+
for (const [id, m] of Object.entries(x.models)) {
|
|
49
|
+
const p = `models["${id}"]`
|
|
50
|
+
if (!isObj(m)) fail(p, "must be an object")
|
|
51
|
+
if (m.context !== undefined && !posInt(m.context)) fail(`${p}.context`, "must be a positive integer")
|
|
52
|
+
if (m.max_output !== undefined && !posInt(m.max_output)) fail(`${p}.max_output`, "must be a positive integer")
|
|
53
|
+
if (m.vision !== undefined && typeof m.vision !== "boolean") fail(`${p}.vision`, "must be true or false")
|
|
54
|
+
if (m.tools !== undefined && typeof m.tools !== "boolean") fail(`${p}.tools`, "must be true or false")
|
|
55
|
+
if (m.note !== undefined && typeof m.note !== "string") fail(`${p}.note`, "must be a string")
|
|
56
|
+
if (m.price !== undefined) {
|
|
57
|
+
const pr = m.price
|
|
58
|
+
if (!isObj(pr) || !nonNeg(pr.input) || !nonNeg(pr.output)) fail(`${p}.price`, "needs non-negative input and output (USD per 1M tokens)")
|
|
59
|
+
for (const k of ["cache_read", "cache_write"] as const) {
|
|
60
|
+
if (pr[k] !== undefined && !nonNeg(pr[k])) fail(`${p}.price.${k}`, "must be a non-negative number")
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const [alias, target] of Object.entries(x.aliases as Record<string, string>)) {
|
|
65
|
+
if (!(target in x.models)) fail(`aliases["${alias}"]`, `points at "${target}", which has no models entry`)
|
|
66
|
+
}
|
|
67
|
+
return x as unknown as ModelsFile
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export type Paths = {
|
|
5
|
+
configDir: string
|
|
6
|
+
stateDir: string
|
|
7
|
+
cacheDir: string
|
|
8
|
+
dataDir: string
|
|
9
|
+
configFile: string
|
|
10
|
+
daemonFile: string
|
|
11
|
+
tokenFile: string
|
|
12
|
+
lockFile: string
|
|
13
|
+
logFile: string
|
|
14
|
+
statsDb: string
|
|
15
|
+
modelsCache: string
|
|
16
|
+
modelsEtag: string
|
|
17
|
+
ufrModelsCache: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const APP = "opencode-ufr"
|
|
21
|
+
|
|
22
|
+
export function resolvePaths(
|
|
23
|
+
env: Record<string, string | undefined> = process.env,
|
|
24
|
+
platform: string = process.platform,
|
|
25
|
+
home: string = homedir(),
|
|
26
|
+
): Paths {
|
|
27
|
+
let configDir: string, stateDir: string, cacheDir: string, dataDir: string
|
|
28
|
+
if (env.OPENCODE_UFR_HOME) {
|
|
29
|
+
const base = env.OPENCODE_UFR_HOME
|
|
30
|
+
configDir = join(base, "config")
|
|
31
|
+
stateDir = join(base, "state")
|
|
32
|
+
cacheDir = join(base, "cache")
|
|
33
|
+
dataDir = join(base, "data")
|
|
34
|
+
} else if (platform === "win32") {
|
|
35
|
+
const roaming = env.APPDATA ?? join(home, "AppData", "Roaming")
|
|
36
|
+
const local = env.LOCALAPPDATA ?? join(home, "AppData", "Local")
|
|
37
|
+
configDir = join(roaming, APP)
|
|
38
|
+
stateDir = join(local, APP, "state")
|
|
39
|
+
cacheDir = join(local, APP, "cache")
|
|
40
|
+
dataDir = join(local, APP, "data")
|
|
41
|
+
} else {
|
|
42
|
+
configDir = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), APP)
|
|
43
|
+
stateDir = join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), APP)
|
|
44
|
+
cacheDir = join(env.XDG_CACHE_HOME ?? join(home, ".cache"), APP)
|
|
45
|
+
dataDir = join(env.XDG_DATA_HOME ?? join(home, ".local", "share"), APP)
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
configDir,
|
|
49
|
+
stateDir,
|
|
50
|
+
cacheDir,
|
|
51
|
+
dataDir,
|
|
52
|
+
configFile: join(configDir, "config.json"),
|
|
53
|
+
daemonFile: join(stateDir, "daemon.json"),
|
|
54
|
+
tokenFile: join(stateDir, "token"),
|
|
55
|
+
lockFile: join(stateDir, "daemon.lock"),
|
|
56
|
+
logFile: join(stateDir, "daemon.log"),
|
|
57
|
+
statsDb: join(dataDir, "stats.db"),
|
|
58
|
+
modelsCache: join(cacheDir, "models.json"),
|
|
59
|
+
modelsEtag: join(cacheDir, "models.etag"),
|
|
60
|
+
ufrModelsCache: join(cacheDir, "ufr-models.json"),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface SecretStore {
|
|
2
|
+
get(alias: string): Promise<string | null>
|
|
3
|
+
set(alias: string, value: string): Promise<void>
|
|
4
|
+
delete(alias: string): Promise<boolean>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const SERVICE = "opencode-ufr"
|
|
8
|
+
|
|
9
|
+
/** Keyring aliases for the built-in VPN's uni login (never listed as API keys). */
|
|
10
|
+
export const VPN_USER = "vpn-login"
|
|
11
|
+
export const VPN_PASS = "vpn-pass"
|
|
12
|
+
|
|
13
|
+
export class SecretStoreError extends Error {}
|
|
14
|
+
|
|
15
|
+
type Backend = {
|
|
16
|
+
get(o: { service: string; name: string }): Promise<string | null>
|
|
17
|
+
set(o: { service: string; name: string; value: string }): Promise<void>
|
|
18
|
+
delete(o: { service: string; name: string }): Promise<boolean>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** UFR keys in the OS keyring: macOS Keychain, Windows Credential Manager, libsecret on Linux. */
|
|
22
|
+
export class KeyringStore implements SecretStore {
|
|
23
|
+
constructor(private readonly backend: Backend = Bun.secrets as unknown as Backend) {}
|
|
24
|
+
|
|
25
|
+
private async run<T>(what: string, fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
try {
|
|
27
|
+
return await fn()
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new SecretStoreError(
|
|
30
|
+
`OS keyring unavailable while trying to ${what} (${(e as Error).message}). ` +
|
|
31
|
+
"On Linux this needs a Secret Service (gnome-keyring or KWallet) running in your session.",
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(alias: string): Promise<string | null> {
|
|
37
|
+
return this.run(`read key "${alias}"`, () => this.backend.get({ service: SERVICE, name: alias }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async set(alias: string, value: string): Promise<void> {
|
|
41
|
+
if (!value) throw new SecretStoreError("refusing to store an empty key")
|
|
42
|
+
await this.run(`store key "${alias}"`, () => this.backend.set({ service: SERVICE, name: alias, value }))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
delete(alias: string): Promise<boolean> {
|
|
46
|
+
return this.run(`delete key "${alias}"`, () => this.backend.delete({ service: SERVICE, name: alias }))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** In-memory store for tests. */
|
|
51
|
+
export class MemoryStore implements SecretStore {
|
|
52
|
+
private readonly m = new Map<string, string>()
|
|
53
|
+
async get(alias: string): Promise<string | null> {
|
|
54
|
+
return this.m.get(alias) ?? null
|
|
55
|
+
}
|
|
56
|
+
async set(alias: string, value: string): Promise<void> {
|
|
57
|
+
this.m.set(alias, value)
|
|
58
|
+
}
|
|
59
|
+
async delete(alias: string): Promise<boolean> {
|
|
60
|
+
return this.m.delete(alias)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** setTimeout as a promise that rejects as soon as the signal aborts. */
|
|
2
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
if (signal?.aborted) return reject(signal.reason)
|
|
5
|
+
const onAbort = () => {
|
|
6
|
+
clearTimeout(timer)
|
|
7
|
+
reject(signal!.reason)
|
|
8
|
+
}
|
|
9
|
+
const timer = setTimeout(() => {
|
|
10
|
+
signal?.removeEventListener("abort", onAbort)
|
|
11
|
+
resolve()
|
|
12
|
+
}, ms)
|
|
13
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const VPN_GUIDE = "https://wiki.uni-freiburg.de/rz/doku.php?id=vpn"
|
|
2
|
+
|
|
3
|
+
export const VPN_MESSAGE = `UFR answered with its "VPN erforderlich" page — connect to the uni VPN (${VPN_GUIDE})`
|
|
4
|
+
|
|
5
|
+
/** Off the VPN, UFR answers every path with HTTP 200 and this page (measured 2026-09-28). */
|
|
6
|
+
export function isVpnPage(html: string): boolean {
|
|
7
|
+
return /VPN erforderlich|Zugriff eingeschr/i.test(html)
|
|
8
|
+
}
|