Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/disks.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { config } from './config.js';
import { jsonb, sql } from './db.js';
import { safeFetch } from './fetch.js';
import { descriptorUrl, diskId, toOpenServerOffer, validate } from './opendisk.js';
import {
c0mputeOf,
descriptorUrl,
diskId,
toOpenServerOffer,
validate,
wantsHosting,
} from './opendisk.js';

export const SORTS = ['price', 'free', 'standing', 'updated', 'name'];

const num = (v) => (v === null || v === undefined ? null : Number(v));

function row(r) {
if (!r) return null;
const c0 = c0mputeOf(jsonb(r.data, {}));
return {
id: r.id,
origin: r.origin,
Expand Down Expand Up @@ -37,6 +45,10 @@ function row(r) {
gone_since: r.gone_since,
last_error: r.last_error,
data: jsonb(r.data, {}),
// A c0mpute node that holds static sites: a host for `c0mpute hosting publish --disks`.
c0mpute_hosting: c0?.hosting === true,
c0mpute_peer_id: c0?.peer_id ?? null,
hosting_per_gib_month: c0?.hosting ? (c0.hosting_price ?? num(r.per_gib_month)) : null,
};
}

Expand Down Expand Up @@ -187,6 +199,11 @@ function whereClause(p) {
Number.isFinite(Number(p.min_standing))
)
parts.push(sql`standing >= ${Number(p.min_standing)}`);
if (wantsHosting(p.hosting)) {
// Same rule as c0mputeOf(): a c0mpute peer id, and accepts.hosting or the hosting role.
parts.push(sql`(data->'c0mpute'->>'peer_id' is not null and (
data->'accepts'->'hosting' = 'true'::jsonb or data->'c0mpute'->'roles' @> ${['hosting']}::jsonb))`);
}
if (p.q) {
const like = `%${String(p.q).replace(/[%_\\]/g, '\\$&')}%`;
parts.push(sql`(name ilike ${like} or origin ilike ${like})`);
Expand Down
40 changes: 40 additions & 0 deletions src/opendisk.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ export function validate(doc) {
encrypted_only: typeof accepts.encrypted_only === 'boolean' ? accepts.encrypted_only : null,
max_file_gib: isNum(accepts.max_file_gib) ? accepts.max_file_gib : null,
feeds: typeof accepts.feeds === 'boolean' ? accepts.feeds : null,
hosting: typeof accepts.hosting === 'boolean' ? accepts.hosting : null,
},
c0mpute: c0mputeOf(doc),
proof: {
kinds: proofKinds.filter((k) => PROOF_KINDS.includes(k)),
every_hours_min:
Expand All @@ -185,6 +187,44 @@ export function validate(doc) {
return { ok: true, errors, warnings, disk };
}

/**
* The c0mpute block a c0mpute node adds to its descriptor (unknown to
* OpenDisk, kept in the raw document): how to reach the disk on the c0mpute
* network and whether it hosts static sites. Null when absent or unusable.
*/
export function c0mputeOf(doc) {
if (!isObj(doc) || !isObj(doc.c0mpute)) return null;
const c = doc.c0mpute;
const peerId =
isStr(c.peer_id) && /^[1-9A-HJ-NP-Za-km-z]{40,64}$/.test(c.peer_id) ? c.peer_id : null;
if (!peerId) return null;
const roles = strList(c.roles);
const accepts = isObj(doc.accepts) ? doc.accepts : {};
const h = isObj(c.hosting) ? c.hosting : {};
const hosting = accepts.hosting === true || roles.includes('hosting');
return {
peer_id: peerId,
version: isStr(c.version) ? c.version : null,
roles,
hosting,
hosting_price:
hosting && isNum(h.price_per_gib_month) && h.price_per_gib_month >= 0
? h.price_per_gib_month
: null,
hosting_max_gib: hosting && isNum(h.max_gib) && h.max_gib >= 0 ? h.max_gib : null,
};
}

/** True for a c0mpute node that holds static sites (`c0mpute hosting publish --disks`). */
export function isC0mputeHosting(doc) {
return c0mputeOf(doc)?.hosting === true;
}

/** The `hosting` listing parameter: true to list only c0mpute hosting disks. */
export function wantsHosting(v) {
return v === '1' || v === 'true' || v === 'c0mpute' || v === true;
}

/** One seeder identity is one disk, whatever URL it was found at. */
export function diskId(disk, origin) {
if (disk.key) return disk.key.replace(':', '-');
Expand Down
24 changes: 24 additions & 0 deletions src/views/disks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ export function Disks({ result, page, params }) {
</option>
</select>
</label>
<label>
Kind
<select name="hosting">
<option value="">any disk</option>
<option value="1" selected={params.hosting === '1'}>
c0mpute hosting
</option>
</select>
</label>
<label>
Sort
<select name="sort">
Expand Down Expand Up @@ -210,6 +219,21 @@ export function Disk({ disk: d, offer, notice }) {
<Warnings warnings={d.warnings} />
</section>

{d.c0mpute_hosting ? (
<section class="band">
<h2>c0mpute hosting node</h2>
<p>
This disk is a <a href="https://c0mpute.com">c0mpute</a> node that holds static sites
for the network, at {fmtPrice(d.hosting_per_gib_month ?? d.per_gib_month, d.currency)}{' '}
per GiB-month. Pick it as a paid host with:
</p>
<Pre>{`c0mpute hosting publish ./public --name my-site --disks auto
# or by peer id, through a d1sks query:
c0mpute hosting disks --country ${d.countries[0] ?? 'DE'}`}</Pre>
<p class="muted mono">peer {d.c0mpute_peer_id}</p>
</section>
) : null}

<section class="band two">
<div>
<h2>Policy</h2>
Expand Down
23 changes: 23 additions & 0 deletions src/views/docs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export function Docs() {
<td class="mono">min_standing</td>
<td>hub standing at least, as copied into the descriptor</td>
</tr>
<tr>
<td class="mono">hosting</td>
<td>1 for c0mpute nodes that hold static sites</td>
</tr>
<tr>
<td class="mono">q</td>
<td>name or origin contains</td>
Expand Down Expand Up @@ -98,6 +102,25 @@ curl -s '${s}/api/v1/stats'`}</Pre>
the hub itself once one is running.
</p>

<h2>Host a site on these disks</h2>
<p>
Every <a href="https://c0mpute.com">c0mpute</a> node with the hosting role lists itself
here with <code>accepts.hosting: true</code>, its peer id and its hosting price in the{' '}
<code>c0mpute</code> block of its descriptor. <code>?hosting=1</code> lists only those. To
pay some of them to keep your static site:
</p>
<Pre>{`c0mpute hosting publish ./public --name my-site --disks auto
c0mpute hosting publish ./public --name my-site --disks auto --country DE,FI --max-price 0.01 --copies 3
c0mpute hosting disks --country DE # what --disks auto would pick`}</Pre>
<p>
The publisher asks d1sks once, ranks the hosting disks by price, standing and free space,
and signs the chosen peer ids and the agreed price into its site record. Everything after
that is peer to peer: the chosen nodes replicate first, the network still keeps at least
three copies, the publisher challenges holders with random chunk fetches, and pays the
ones that pass per GiB-month. d1sks is discovery only; a site keeps being served if d1sks
is down.
</p>

<h2>Validating</h2>
<Pre>{`curl -s '${s}/api/v1/validate?url=https://your-disk.example'
curl -s -X POST '${s}/api/v1/validate' -H 'content-type: application/json' --data-binary @opendisk.json`}</Pre>
Expand Down
11 changes: 11 additions & 0 deletions src/views/parts.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export function DiskRow({ d }) {
<strong>{d.name}</strong>
<span class="muted mono">{new URL(d.origin).host}</span>
{d.status === 'gone' ? <span class="badge gone">gone</span> : null}
{d.c0mpute_hosting ? (
<span class="badge ok" title="A c0mpute node that holds static sites">
c0mpute hosting
</span>
) : null}
</div>
<div class="row-meta">
<span title="Free">{fmtGib(d.free_gib)} free</span>
Expand All @@ -61,6 +66,12 @@ export function DiskRow({ d }) {
<span title="Hub standing">
{d.standing === null ? 'standing unstated' : `standing ${d.standing}`}
</span>
{d.c0mpute_hosting && d.hosting_per_gib_month !== null ? (
<span title="Price to hold a static site, per GiB-month">
hosting {fmtPrice(d.hosting_per_gib_month)}
<small>/GiB·mo</small>
</span>
) : null}
</div>
</a>
);
Expand Down
34 changes: 34 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ describe('d1sks end to end', () => {
expect(offer.price.amount).toBe(0.07);
});

it('filters c0mpute hosting disks and flags them in rows', async () => {
if (!dbOk) return;
const plain = await (await req('/api/v1/disks?hosting=1&country=PT')).json();
expect(plain.disks.some((d) => d.origin === origin)).toBe(false);
const keep = doc;
doc = {
...doc,
accepts: { ...doc.accepts, hosting: true },
c0mpute: {
peer_id: '12D3KooWSFxwveBjrGy4n1Djg3vpQD5aHPCXYCQHZWruffig7P4J',
roles: ['storage', 'hosting'],
hosting: { price_per_gib_month: 0.003, max_gib: 5 },
},
};
const r = await req('/api/v1/disks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: origin }),
});
expect(r.status).toBe(201);
const list = await (await req('/api/v1/disks?hosting=1&country=PT')).json();
const row = list.disks.find((d) => d.origin === origin);
expect(row?.c0mpute_hosting).toBe(true);
expect(row.hosting_per_gib_month).toBe(0.003);
const html = await (await req('/disks?hosting=1&country=PT')).text();
expect(html).toContain('c0mpute hosting');
doc = keep;
await req('/api/v1/disks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: origin }),
});
});

it('refuses a descriptor that is not valid, with the reason', async () => {
if (!dbOk) return;
const bad = { ...doc };
Expand Down
72 changes: 71 additions & 1 deletion test/opendisk.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'bun:test';
import { descriptorUrl, diskId, generate, toOpenServerOffer, validate } from '../src/opendisk.js';
import {
c0mputeOf,
descriptorUrl,
diskId,
generate,
isC0mputeHosting,
toOpenServerOffer,
validate,
wantsHosting,
} from '../src/opendisk.js';

const FULL = {
name: 'seeder-a41e, Falkenstein',
Expand Down Expand Up @@ -34,6 +43,67 @@ const FULL = {
extra: { anything: true },
};

const PEER = '12D3KooWSFxwveBjrGy4n1Djg3vpQD5aHPCXYCQHZWruffig7P4J';

/** What a c0mpute node with the hosting role serves (node/crates/c0mpute-core/src/opendisk.rs). */
const c0mputeDoc = (over = {}) => ({
opendisk: '0.1',
name: 'c0mpute node ffig7P4J',
capacity: { free_gib: 900, total_gib: 1000 },
price: { currency: 'USD', per_gib_month: 0.004 },
accepts: { visibility: ['public', 'private'], encrypted_only: false, hosting: true },
c0mpute: {
peer_id: PEER,
version: '0.2.79',
roles: ['storage', 'gateway', 'hosting'],
hosting: { price_per_gib_month: 0.003, max_gib: 5 },
},
...over,
});

describe('c0mpute hosting disks', () => {
it('reads the c0mpute block of a hosting node', () => {
const c = c0mputeOf(c0mputeDoc());
expect(c.peer_id).toBe(PEER);
expect(c.hosting).toBe(true);
expect(c.hosting_price).toBe(0.003);
expect(c.hosting_max_gib).toBe(5);
expect(isC0mputeHosting(c0mputeDoc())).toBe(true);
const v = validate(c0mputeDoc());
expect(v.ok).toBe(true);
expect(v.disk.accepts.hosting).toBe(true);
expect(v.disk.c0mpute.peer_id).toBe(PEER);
});

it('counts the hosting role or accepts.hosting, either one', () => {
const roleOnly = c0mputeDoc({ accepts: {} });
expect(isC0mputeHosting(roleOnly)).toBe(true);
const flagOnly = c0mputeDoc();
flagOnly.c0mpute = { ...flagOnly.c0mpute, roles: ['storage'] };
expect(isC0mputeHosting(flagOnly)).toBe(true);
});

it('is not a hosting disk without the role, a c0mpute block or a real peer id', () => {
const storageOnly = c0mputeDoc({ accepts: { hosting: false } });
storageOnly.c0mpute = { peer_id: PEER, roles: ['storage'] };
expect(isC0mputeHosting(storageOnly)).toBe(false);
expect(c0mputeOf(storageOnly).hosting_price).toBe(null);
expect(isC0mputeHosting({ name: 'x', accepts: { hosting: true } })).toBe(false);
const badPeer = c0mputeDoc();
badPeer.c0mpute = { ...badPeer.c0mpute, peer_id: 'not a peer id' };
expect(c0mputeOf(badPeer)).toBe(null);
expect(isC0mputeHosting(null)).toBe(false);
expect(
validate({ name: 'x', capacity: { free_gib: 1 }, price: { per_gib_month: 1 } }).disk.c0mpute,
).toBe(null);
});

it('parses the hosting listing parameter', () => {
for (const v of ['1', 'true', 'c0mpute']) expect(wantsHosting(v)).toBe(true);
for (const v of [undefined, '', '0', 'false', 'yes']) expect(wantsHosting(v)).toBe(false);
});
});

describe('descriptorUrl', () => {
it('normalises a host, an origin and the file itself to the well-known path', () => {
expect(descriptorUrl('example.com')).toBe('https://example.com/.well-known/opendisk.json');
Expand Down
Loading