fedipod-server 0.7.0 → 0.8.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/README.md +28 -8
- package/config/server.json +11 -0
- package/dist/claims.js +5 -4
- package/dist/components/context.jsonld +6 -0
- package/dist/directory.d.ts +11 -0
- package/dist/directory.js +19 -0
- package/dist/handler.d.ts +18 -2
- package/dist/handler.js +63 -27
- package/dist/handler.jsonld +27 -0
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +176 -6
- package/lib/bskygroup.mjs +1 -1
- package/lib/embed.mjs +6 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/front-core.mjs +74 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +101 -2
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +2 -2
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +31 -0
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +133 -0
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +20 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/remote.mjs
CHANGED
|
@@ -29,6 +29,12 @@ export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
|
|
|
29
29
|
// opened — and the callers' existing retry paths take it from there.
|
|
30
30
|
// The parser is shared with the outbound path; see lib/safefetch.mjs.
|
|
31
31
|
import { retryAfterMs, readCapped } from './safefetch.mjs';
|
|
32
|
+
import { linkTargets, REL } from './links.mjs';
|
|
33
|
+
|
|
34
|
+
// A pod whose access rules are ACP policies, not WAC authorizations. This
|
|
35
|
+
// agent writes WAC; over an ACP resource that would be noise where the pod's
|
|
36
|
+
// real rules used to be, so it stops instead.
|
|
37
|
+
const ACP_NS = 'http://www.w3.org/ns/solid/acp#';
|
|
32
38
|
|
|
33
39
|
// The inbox is public-Append, so the listing's size is in other people's
|
|
34
40
|
// hands; reading it whole must still have a ceiling.
|
|
@@ -82,6 +88,11 @@ export class RemotePod {
|
|
|
82
88
|
this.pausedUntil = 0;
|
|
83
89
|
this.probeCount = 0;
|
|
84
90
|
this.log = log;
|
|
91
|
+
// Where each resource's access control lives, as the pod itself said. WAC
|
|
92
|
+
// forbids working it out from the resource's own URL, so it is asked for
|
|
93
|
+
// and remembered rather than assembled.
|
|
94
|
+
this.aclUrls = new Map();
|
|
95
|
+
this.aclFlavour = null; // null until the first write asks what this pod speaks
|
|
85
96
|
// A fronted identity advertises ids on a shared domain but writes to the
|
|
86
97
|
// pod. run-agent installs the fronted→pod mapping here, so every request
|
|
87
98
|
// built from an advertised id lands on the pod — one choke point, and
|
|
@@ -153,9 +164,63 @@ export class RemotePod {
|
|
|
153
164
|
method: 'PUT', headers: { 'content-type': contentType }, body,
|
|
154
165
|
});
|
|
155
166
|
if (res.status >= 400) throw new Error(`PUT ${url} → ${res.status}`);
|
|
167
|
+
// Writing a document is usually the step before setting its access, and
|
|
168
|
+
// the answer to the write already says where that lives. Taking it here
|
|
169
|
+
// spares the extra request the ACL write would otherwise make.
|
|
170
|
+
this.noteAclLink(url, res);
|
|
156
171
|
return res;
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/** Remember an access-control location the pod volunteered on a response. */
|
|
175
|
+
noteAclLink(url, res) {
|
|
176
|
+
if (this.aclUrls.has(url)) return;
|
|
177
|
+
const [acl] = linkTargets(res?.headers?.get?.('link'), REL.acl, url);
|
|
178
|
+
if (acl) this.aclUrls.set(url, acl);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where this resource's access control lives. The pod says so on any
|
|
183
|
+
* response about the resource; a pod that says nothing is taken to keep it
|
|
184
|
+
* at the usual suffix, which is what every server this runs against does.
|
|
185
|
+
*/
|
|
186
|
+
async aclUrlFor(targetUrl) {
|
|
187
|
+
const known = this.aclUrls.get(targetUrl);
|
|
188
|
+
if (known) return known;
|
|
189
|
+
try {
|
|
190
|
+
const res = await this.fetch(targetUrl, { method: 'HEAD' });
|
|
191
|
+
this.noteAclLink(targetUrl, res);
|
|
192
|
+
} catch { /* unreachable or no such resource yet: the suffix below */ }
|
|
193
|
+
const resolved = this.aclUrls.get(targetUrl) || targetUrl + '.acl';
|
|
194
|
+
this.aclUrls.set(targetUrl, resolved);
|
|
195
|
+
return resolved;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether writing a WAC document here is meaningful. Asked once per pod, on
|
|
200
|
+
* the first access-control write. A pod that answers with ACP policies is
|
|
201
|
+
* left alone: replacing them with authorizations it does not read would take
|
|
202
|
+
* away the rules actually protecting it.
|
|
203
|
+
*/
|
|
204
|
+
async aclWritable(aclUrl) {
|
|
205
|
+
if (this.aclFlavour !== null) return this.aclFlavour;
|
|
206
|
+
this.aclFlavour = true;
|
|
207
|
+
try {
|
|
208
|
+
const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
|
|
209
|
+
if (res.status < 300) {
|
|
210
|
+
const g = $rdf.graph();
|
|
211
|
+
$rdf.parse(await res.text(), g, aclUrl, 'text/turtle');
|
|
212
|
+
const acp = g.statements.some(st => st.predicate.value.startsWith(ACP_NS)
|
|
213
|
+
|| st.object.value.startsWith(ACP_NS));
|
|
214
|
+
if (acp) {
|
|
215
|
+
this.aclFlavour = false;
|
|
216
|
+
this.log('this pod states access as ACP policies, which this agent does not write — '
|
|
217
|
+
+ 'its access rules are left exactly as they are, and nothing here is published private');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch { /* absent, unreadable or unparsable: WAC is what we write */ }
|
|
221
|
+
return this.aclFlavour;
|
|
222
|
+
}
|
|
223
|
+
|
|
159
224
|
async putJson(url, obj, contentType = 'application/activity+json') {
|
|
160
225
|
return this.put(url, JSON.stringify(obj), contentType);
|
|
161
226
|
}
|
|
@@ -173,6 +238,11 @@ export class RemotePod {
|
|
|
173
238
|
|
|
174
239
|
async delete(url) {
|
|
175
240
|
protectedFromDeletion(url);
|
|
241
|
+
// The pattern list above knows the usual name for an access-control
|
|
242
|
+
// document. One the pod named itself is just as fatal to remove.
|
|
243
|
+
for (const acl of this.aclUrls.values()) {
|
|
244
|
+
if (acl === url) throw new Error(`refusing to DELETE an access-control document: ${url}`);
|
|
245
|
+
}
|
|
176
246
|
const res = await this.fetch(url, { method: 'DELETE' });
|
|
177
247
|
return res.status < 400 || res.status === 404;
|
|
178
248
|
}
|
|
@@ -238,8 +308,8 @@ export class RemotePod {
|
|
|
238
308
|
// $rdf.sym() also throws on an illegal IRI, so a pod URL with something odd
|
|
239
309
|
// in it fails here rather than silently producing a document that means
|
|
240
310
|
// something else.
|
|
241
|
-
aclDoc(targetUrl, publicModes, { appendAgents = [] } = {}) {
|
|
242
|
-
const url = targetUrl + '.acl';
|
|
311
|
+
aclDoc(targetUrl, publicModes, { appendAgents = [], aclUrl = null } = {}) {
|
|
312
|
+
const url = aclUrl || targetUrl + '.acl';
|
|
243
313
|
const doc = $rdf.sym(url);
|
|
244
314
|
const target = $rdf.sym(targetUrl);
|
|
245
315
|
const g = $rdf.graph();
|
|
@@ -263,7 +333,9 @@ export class RemotePod {
|
|
|
263
333
|
}
|
|
264
334
|
|
|
265
335
|
async setAcl(targetUrl, publicModes, opts = {}) {
|
|
266
|
-
|
|
336
|
+
const url = await this.aclUrlFor(targetUrl);
|
|
337
|
+
if (!await this.aclWritable(url)) return null;
|
|
338
|
+
return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
|
|
267
339
|
}
|
|
268
340
|
|
|
269
341
|
// The WebID profile advertises the actor as an account:
|
|
@@ -295,9 +367,53 @@ export class RemotePod {
|
|
|
295
367
|
const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
|
|
296
368
|
.filter(st => st.object.value !== accountName);
|
|
297
369
|
if (!missing.length && !stale.length) return false;
|
|
370
|
+
// A patch touches these statements and nothing else. Rewriting the whole
|
|
371
|
+
// profile re-serialises statements that are not ours — the OIDC issuer
|
|
372
|
+
// among them — and a server is entitled to refuse a write that would.
|
|
373
|
+
const deletes = stale.map(st => [ st.subject, st.predicate, st.object ]);
|
|
374
|
+
if (await this.patchDocument(docUrl, missing, deletes)) return true;
|
|
298
375
|
for (const st of stale) g.remove(st);
|
|
299
376
|
for (const [s, p, o] of missing) g.add(s, p, o, doc);
|
|
300
377
|
await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
|
|
301
378
|
return true;
|
|
302
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* An N3 Patch of exactly these statements, or false when the pod will not
|
|
383
|
+
* take one and the caller should write the document instead.
|
|
384
|
+
*
|
|
385
|
+
* The statements are serialised by rdflib; only the wrapper naming what is
|
|
386
|
+
* being patched is assembled here, because N3's braces have no rdflib form.
|
|
387
|
+
*/
|
|
388
|
+
n3Patch(docUrl, inserts, deletes) {
|
|
389
|
+
const block = (triples) => {
|
|
390
|
+
const g = $rdf.graph();
|
|
391
|
+
for (const [s, p, o] of triples) g.add(s, p, o);
|
|
392
|
+
return $rdf.serialize(null, g, docUrl, 'application/n-triples').trim();
|
|
393
|
+
};
|
|
394
|
+
const clauses = [];
|
|
395
|
+
if (deletes.length) clauses.push(` solid:deletes { ${block(deletes)} }`);
|
|
396
|
+
if (inserts.length) clauses.push(` solid:inserts { ${block(inserts)} }`);
|
|
397
|
+
return `@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n`
|
|
398
|
+
+ `<> a solid:InsertDeletePatch;\n${clauses.join(';\n')}.\n`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async patchDocument(docUrl, inserts, deletes) {
|
|
402
|
+
let res;
|
|
403
|
+
try {
|
|
404
|
+
res = await this.fetch(docUrl, {
|
|
405
|
+
method: 'PATCH',
|
|
406
|
+
headers: { 'content-type': 'text/n3' },
|
|
407
|
+
body: this.n3Patch(docUrl, inserts, deletes),
|
|
408
|
+
});
|
|
409
|
+
} catch {
|
|
410
|
+
return false; // no PATCH on this transport at all
|
|
411
|
+
}
|
|
412
|
+
if (res.status < 300) return true;
|
|
413
|
+
// The pod cannot patch. Anything else — a 409 saying what we meant to
|
|
414
|
+
// remove is not there any more — is a real answer, and rewriting the whole
|
|
415
|
+
// document over the top of it would destroy whatever changed it.
|
|
416
|
+
if (res.status === 405 || res.status === 415 || res.status === 501) return false;
|
|
417
|
+
throw new Error(`PATCH ${docUrl} → ${res.status}`);
|
|
418
|
+
}
|
|
303
419
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import * as $rdf from 'rdflib';
|
|
14
15
|
|
|
15
16
|
import { createAccountWithPod as realCreateAccount } from './account.mjs';
|
|
16
17
|
import { mintCredential as realMint } from './remote.mjs';
|
|
@@ -20,8 +21,48 @@ import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
|
20
21
|
import { insecureUrlReason } from './safefetch.mjs';
|
|
21
22
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
22
23
|
|
|
24
|
+
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
25
|
+
|
|
23
26
|
export const STEPS = ['account', 'credential', 'bootstrap', 'connect', 'publish', 'verify'];
|
|
24
27
|
|
|
28
|
+
// Read-only pre-check on the pod behind an existing-pod setup, before a
|
|
29
|
+
// credential is minted or the first write is attempted. Two failures it turns
|
|
30
|
+
// from a bare "PUT … → 401" into a plain sentence:
|
|
31
|
+
// 1. the pod host is unreachable — no account should be built on a pod that
|
|
32
|
+
// is not there;
|
|
33
|
+
// 2. the pod's WebID document declares no OIDC issuer. A pod verifies a token
|
|
34
|
+
// by dereferencing its WebID and looking for solid:oidcIssuer; an empty or
|
|
35
|
+
// issuer-less profile makes it reject every write with 401, so setup would
|
|
36
|
+
// stall on the first one. Only judged when the document is publicly
|
|
37
|
+
// readable — a protected one cannot be read here and is left to bootstrap.
|
|
38
|
+
export async function checkPodUsable(pod, { webId, fetch: doFetch = globalThis.fetch } = {}) {
|
|
39
|
+
const base = pod.endsWith('/') ? pod : pod + '/';
|
|
40
|
+
const wid = webId || new URL('profile/card#me', base).href;
|
|
41
|
+
const cardUrl = wid.split('#')[0];
|
|
42
|
+
|
|
43
|
+
let root;
|
|
44
|
+
try { root = await doFetch(base, { headers: { accept: 'text/turtle' } }); }
|
|
45
|
+
catch (e) { return { ok: false, error: `${base} could not be reached (${e.message}). Check the address and that the server is running.` }; }
|
|
46
|
+
if (root.status === 404) return { ok: false, error: `there is no pod at ${base} — the server answered 404.` };
|
|
47
|
+
if (root.status >= 500) return { ok: false, error: `the pod host at ${base} is not responding (HTTP ${root.status}).` };
|
|
48
|
+
|
|
49
|
+
let card;
|
|
50
|
+
try { card = await doFetch(cardUrl, { headers: { accept: 'text/turtle' } }); }
|
|
51
|
+
catch (e) { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be read (${e.message}).` }; }
|
|
52
|
+
if (card.status === 404) return { ok: false, error: `the pod has no WebID document at ${cardUrl} — the identity it needs is missing.` };
|
|
53
|
+
if (card.status >= 500) return { ok: false, error: `the pod host is not serving ${cardUrl} (HTTP ${card.status}).` };
|
|
54
|
+
if (card.status === 401 || card.status === 403) return { ok: true }; // cannot read unauthenticated; leave it to bootstrap
|
|
55
|
+
|
|
56
|
+
const body = await card.text().catch(() => '');
|
|
57
|
+
const g = $rdf.graph();
|
|
58
|
+
try { $rdf.parse(body, g, cardUrl, (card.headers.get('content-type') || 'text/turtle').split(';')[0].trim()); }
|
|
59
|
+
catch { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be parsed as RDF.` }; }
|
|
60
|
+
if (!g.each($rdf.sym(wid), SOLID('oidcIssuer'), null).length) {
|
|
61
|
+
return { ok: false, error: `the pod's WebID document ${cardUrl} declares no OIDC issuer, so the pod will reject every write. It looks empty or incomplete — restore it, or set up with a fresh pod.` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export const credentialPath = (home) => path.join(home, 'credential.json');
|
|
26
67
|
export const hasCredential = (home) => fs.existsSync(credentialPath(home));
|
|
27
68
|
|
|
@@ -118,6 +159,7 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
|
118
159
|
export async function runSetup({ home, agent, answers, run, deps = {}, log = () => {} }) {
|
|
119
160
|
const createAccount = deps.createAccountWithPod || realCreateAccount;
|
|
120
161
|
const mint = deps.mintCredential || realMint;
|
|
162
|
+
const checkPod = deps.checkPodUsable || checkPodUsable;
|
|
121
163
|
|
|
122
164
|
const at = (key) => run.steps.find(s => s.key === key);
|
|
123
165
|
const begin = (key) => { at(key).state = 'running'; };
|
|
@@ -166,14 +208,21 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
166
208
|
}
|
|
167
209
|
done('account', pod);
|
|
168
210
|
} else {
|
|
211
|
+
// A pod you bring must be there and carry a usable WebID before a
|
|
212
|
+
// credential is minted against it — no account on a pod that is not
|
|
213
|
+
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
214
|
+
const usable = await checkPod(pod);
|
|
215
|
+
if (!usable.ok) throw new Error(usable.error);
|
|
169
216
|
skip('account', 'using the pod you already have');
|
|
170
217
|
}
|
|
171
218
|
|
|
172
219
|
// --- credential: the point of no return, and the durability boundary ---
|
|
220
|
+
let resumeWebId = null;
|
|
173
221
|
if (resuming) {
|
|
174
222
|
const rec = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
175
223
|
pod = rec.remotePod;
|
|
176
224
|
root = rec.root;
|
|
225
|
+
resumeWebId = rec.webId || null;
|
|
177
226
|
skip('credential', `already minted — ${credPath}`);
|
|
178
227
|
} else {
|
|
179
228
|
begin('credential');
|
|
@@ -212,6 +261,13 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
212
261
|
|
|
213
262
|
// --- provision the pod and bring federation up ---
|
|
214
263
|
begin('bootstrap');
|
|
264
|
+
// Resuming skipped the checks the fresh paths ran, and the credential it
|
|
265
|
+
// carries may be the one bound to a pod whose profile is empty — the write
|
|
266
|
+
// below would 401. Catch it here, as this step, with a sentence.
|
|
267
|
+
if (resuming) {
|
|
268
|
+
const ready = await checkPod(pod, { webId: resumeWebId || undefined });
|
|
269
|
+
if (!ready.ok) throw new Error(ready.error);
|
|
270
|
+
}
|
|
215
271
|
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
|
|
216
272
|
done('bootstrap');
|
|
217
273
|
|
package/lib/store.mjs
CHANGED
|
@@ -343,7 +343,8 @@ export class PodStore {
|
|
|
343
343
|
}
|
|
344
344
|
addStatus(s) {
|
|
345
345
|
const all = this.getStatuses();
|
|
346
|
-
|
|
346
|
+
const at = all.findIndex(x => x.noteId === s.noteId);
|
|
347
|
+
if (at >= 0) return this._mergeStatus(all, at, s);
|
|
347
348
|
// Same reason as cacheActor: statuses.json is serialized whole on every
|
|
348
349
|
// change, and remote content is only bounded by the 5 MB fetch ceiling.
|
|
349
350
|
if (typeof s.content === 'string' && s.content.length > MAX_CONTENT) {
|
|
@@ -352,6 +353,30 @@ export class PodStore {
|
|
|
352
353
|
all.unshift(s);
|
|
353
354
|
this.write('statuses.json', all.slice(0, 1000));
|
|
354
355
|
this.onEvent?.('status', s); // streaming subscribers
|
|
356
|
+
return { added: true, merged: false, status: s };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The same post arrives twice when two of the owner's accounts follow its
|
|
360
|
+
// author, and the second arrival is the only record that the other one saw
|
|
361
|
+
// it. Merged in place — the per-kind prune tails take the tail to be the
|
|
362
|
+
// oldest — and with no stream event, which would show the post twice.
|
|
363
|
+
_mergeStatus(all, at, s) {
|
|
364
|
+
const row = all[at];
|
|
365
|
+
const known = new Set((row.sourceAccts || []).map(v => v.acct));
|
|
366
|
+
const fresh = (s.sourceAccts || []).filter(v => v && !known.has(v.acct));
|
|
367
|
+
// Only our own verified intake may raise a row's kind. A general ladder
|
|
368
|
+
// would let a source that merely SAW a post promote a stranger's mention
|
|
369
|
+
// into the home timeline, which is the route tagfeed had to close.
|
|
370
|
+
const first = (k) => k === 'post' || k === 'timeline';
|
|
371
|
+
const raise = first(s.kind) && !first(row.kind);
|
|
372
|
+
if (!fresh.length && !raise) return { added: false, merged: false, status: row };
|
|
373
|
+
all[at] = {
|
|
374
|
+
...row,
|
|
375
|
+
...(fresh.length ? { sourceAccts: [...(row.sourceAccts || []), ...fresh] } : {}),
|
|
376
|
+
...(raise ? { kind: s.kind, ...(s.slug ? { slug: s.slug } : {}) } : {}),
|
|
377
|
+
};
|
|
378
|
+
this.write('statuses.json', all);
|
|
379
|
+
return { added: false, merged: true, status: all[at] };
|
|
355
380
|
}
|
|
356
381
|
updateStatus(noteId, patch) {
|
|
357
382
|
const all = this.getStatuses();
|
package/lib/wire.mjs
CHANGED
|
@@ -243,8 +243,8 @@ ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
|
|
|
243
243
|
<h1>${esc(name)}</h1>
|
|
244
244
|
<p class="address">${esc(address)}</p>
|
|
245
245
|
${summary ? `<div>${summary}</div>` : ''}
|
|
246
|
-
<p>This is ${what} on the
|
|
247
|
-
into the search box of Mastodon or any
|
|
246
|
+
<p>This is ${what} on the Fediverse. To follow it, paste the address above
|
|
247
|
+
into the search box of Mastodon or any Fediverse app — or use the form.</p>
|
|
248
248
|
<form id="follow">
|
|
249
249
|
<label for="server">your server</label>
|
|
250
250
|
<input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
|