Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Allow overriding eth.radicle.seed.host record
Alexis Sellier committed 4 years ago
commit 1b7a259515e6a3bc8a1c6850fefc186553a9b4f4
parent 8b8b29d666d13d57e9bf287191ec156145b9fd0b
11 files changed +118 -84
modified src/Form.svelte
@@ -1,7 +1,7 @@
<script context="module" lang="ts">
  export interface Field {
    name: string;
-
    value: string | null;
+
    value?: string;
    label?: string;
    validate?: string;
    placeholder?: string;
@@ -62,7 +62,7 @@
    });
  };

-
  const cleanup = (fields: Field[]): { name: string; value: string | null }[] => {
+
  const cleanup = (fields: Field[]): { name: string; value?: string }[] => {
    return fields.filter(field => field.editable).map(field => {
      return {
        name: field.name,
modified src/api.ts
@@ -5,7 +5,7 @@ export async function get(
  params: Record<string, any>,
  config: Config
): Promise<any> {
-
  if (! config.seed.host) {
+
  if (! config.seed.api.host) {
    throw new Error("Seed host unavailable");
  }

@@ -14,7 +14,7 @@ export async function get(
    query[key] = val.toString();
  }

-
  const base = config.seed.host;
+
  const base = config.seed.api.host;
  const port = config.seed.api.port;
  const search = new URLSearchParams(query).toString();
  const baseUrl = `https://${base}:${port}/v1/${path}`;
modified src/base/projects/Browser.svelte
@@ -321,30 +321,34 @@
          {/await}
        {/if}
      </div>
-
      {#if config.seed.host}
+
      {#if config.seed.git.host}
        <span>
          <div class="clone" on:click={() => cloneDropdown = !cloneDropdown}>
            Clone
          </div>
          <div class="dropdown clone-dropdown" class:clone-dropdown-visible={cloneDropdown}>
-
            <input readonly name="clone-url" value="https://{config.seed.host}/{utils.parseRadicleId(urn)}"/>
+
            <input readonly name="clone-url" value="https://{config.seed.git.host}/{utils.parseRadicleId(urn)}"/>
            <label for="clone-url">Use Git to clone this repository from the URL above.</label>
          </div>
        </span>
-
        <span>
+
      {/if}
+
      <span>
+
        {#if config.seed.api.host}
          <div class="stat seed" on:click={() => seedDropdown = !seedDropdown} title="Project data is fetched from this seed">
-
            <span>{config.seed.host}</span>
-
          </div>
-
          <div class="dropdown seed-dropdown" class:seed-dropdown-visible={seedDropdown}>
-
            {#if config.seed.id}
-
              <input readonly name="clone-url" value={utils.formatSeedAddress(config.seed.id, config.seed.host, config)}/>
-
              <label for="seed-url">Bootstrap your Radicle node with this seed.</label>
-
            {:else if profile}
-
              <label for="#">Seed ID is not set for {profile.name}.</label>
-
            {/if}
+
            <span>{config.seed.api.host}</span>
          </div>
-
        </span>
-
      {/if}
+
        {/if}
+
        <div class="dropdown seed-dropdown" class:seed-dropdown-visible={seedDropdown}>
+
          {#if config.seed.link.id && config.seed.link.host}
+
            <input readonly
+
              name="clone-url"
+
              value={utils.formatSeedAddress(config.seed.link.id, config.seed.link.host, config)}/>
+
            <label for="seed-url">Bootstrap your Radicle node with this seed.</label>
+
          {:else if profile}
+
            <label for="#">Seed ID is not set for {profile.name}.</label>
+
          {/if}
+
        </div>
+
      </span>
      <div class="stat">
        <strong>{tree.stats.commits}</strong> commit(s)
      </div>
modified src/base/projects/View.svelte
@@ -32,9 +32,8 @@
      resolve(null);
    }
  }).then(async (profile) => {
-
    const seedHost = profile?.seedHost;
-
    const seedId = profile?.seedId || undefined;
-
    const cfg = seedHost ? config.withSeed(seedHost, seedId) : config;
+
    const seed = profile?.seed;
+
    const cfg = seed ? config.withSeed(seed) : config;
    const info = await proj.getInfo(urn, cfg);

    projectInfo = info;
modified src/base/registrations/View.svelte
@@ -82,10 +82,10 @@
          description: "The seed host address. " +
            "Only domain names with TLS are supported. " +
            `HTTP(S) API requests use port ${config.seed.api.port}.`,
-
          value: r.profile.seedHost, editable: true },
+
          value: r.profile.seed.host, editable: true },
        { name: "seed.id", label: "Seed ID", validate: "id", placeholder: "hynkyndc6w3p8urucakobzncqny7xxtw88...",
          description: "The Device ID of a Radicle Link node that hosts entities associated with this name.",
-
          value: r.profile.seedId, editable: true },
+
          value: r.profile.seed.id, editable: true },
        { name: "anchors", label: "Anchors", validate: "URN", placeholder: "URN, eg. eip155:1:0x4a9cf21...",
          description: "URN under which associated project anchors can be found. "
            + "To point to a Radicle org on Ethereum, use the CAIP-10 ID, eg. *eip155:1:0x4a9cf21...*",
modified src/base/registrations/registrar.ts
@@ -6,7 +6,7 @@ import type { TypedDataSigner } from '@ethersproject/abstract-signer';
import * as session from '@app/session';
import { Failure } from '@app/error';
import type { Config } from '@app/config';
-
import { unixTime } from '@app/utils';
+
import { isDomain, unixTime } from '@app/utils';
import { assert } from '@app/error';

export interface Registration {
@@ -14,17 +14,38 @@ export interface Registration {
  resolver: EnsResolver;
}

+
export class Seed {
+
  id?: string;
+
  host?: string;
+
  git?: string;
+
  api?: string;
+

+
  constructor(id?: string, host?: string, git?: string, api?: string) {
+
    if (id && /^[a-z0-9]+$/.test(id)) {
+
      this.id = id;
+
    }
+
    if (host && isDomain(host)) {
+
      this.host = host;
+
    }
+
    if (api && isDomain(api)) {
+
      this.api = api;
+
    }
+
    if (git && isDomain(git)) {
+
      this.git = git;
+
    }
+
  }
+
}
+

export interface EnsProfile {
  name: string;
  owner?: string;
-
  address: string | null;
-
  seedId: string | null;
-
  seedHost: string | null;
-
  anchorsAccount: string | null;
-
  url: string | null;
-
  avatar: string | null;
-
  twitter: string | null;
-
  github: string | null;
+
  address?: string;
+
  seed: Seed;
+
  anchorsAccount?: string;
+
  url?: string;
+
  avatar?: string;
+
  twitter?: string;
+
  github?: string;
}

export enum State {
@@ -75,13 +96,15 @@ export async function getRegistration(name: string, config: Config, resolver?: E
    resolver.getText('url'),
    resolver.getText('eth.radicle.seed.id'),
    resolver.getText('eth.radicle.seed.host'),
+
    resolver.getText('eth.radicle.seed.git'),
+
    resolver.getText('eth.radicle.seed.api'),
    resolver.getText('eth.radicle.anchors'),
    resolver.getText('com.twitter'),
    resolver.getText('com.github'),
  ]);

-
  const [address, avatar, url, seedId, seedHost, anchorsAccount, twitter, github] =
-
    meta.map(r => r.status == "fulfilled" ? r.value : null);
+
  const [address, avatar, url, seedId, seedHost, seedGit, seedApi, anchorsAccount, twitter, github] =
+
    meta.map(r => r.status == "fulfilled" && r.value ? r.value : undefined);

  return {
    resolver,
@@ -89,8 +112,12 @@ export async function getRegistration(name: string, config: Config, resolver?: E
      name,
      url,
      avatar,
-
      seedId,
-
      seedHost,
+
      seed: new Seed(
+
        seedId,
+
        seedHost,
+
        seedGit,
+
        seedApi,
+
      ),
      anchorsAccount,
      address,
      twitter,
@@ -109,34 +136,32 @@ export async function getAvatar(name: string, config: Config, resolver?: EnsReso
  return resolver.getText('avatar');
}

-
export async function getSeedHost(name: string, config: Config, resolver?: EnsResolver | null): Promise<string | null> {
+
export async function getAnchorsAccount(name: string, config: Config, resolver?: EnsResolver | null): Promise<string | null> {
  name = name.toLowerCase();

  resolver = resolver ?? await config.provider.getResolver(name);
  if (! resolver) {
    return null;
  }
-
  return resolver.getText('eth.radicle.seed.host');
+
  return resolver.getText('eth.radicle.anchors');
}

-
export async function getSeedId(name: string, config: Config, resolver?: EnsResolver | null): Promise<string | null> {
+
export async function getSeed(name: string, config: Config, resolver?: EnsResolver | null): Promise<Seed | null> {
  name = name.toLowerCase();

  resolver = resolver ?? await config.provider.getResolver(name);
  if (! resolver) {
    return null;
  }
-
  return resolver.getText('eth.radicle.seed.id');
-
}

-
export async function getAnchorsAccount(name: string, config: Config, resolver?: EnsResolver | null): Promise<string | null> {
-
  name = name.toLowerCase();
+
  const [id, host, git, api] = await Promise.all([
+
    resolver.getText('eth.radicle.seed.id'),
+
    resolver.getText('eth.radicle.seed.host'),
+
    resolver.getText('eth.radicle.seed.git'),
+
    resolver.getText('eth.radicle.seed.api'),
+
  ]);

-
  resolver = resolver ?? await config.provider.getResolver(name);
-
  if (! resolver) {
-
    return null;
-
  }
-
  return resolver.getText('eth.radicle.anchors');
+
  return new Seed(id, host, git, api);
}

export function registrar(config: Config): ethers.Contract {
modified src/base/registrations/resolver.ts
@@ -36,6 +36,8 @@ export async function setRecords(name: string, records: EnsRecord[], resolver: E
        break;
      case "seed.id":
      case "seed.host":
+
      case "seed.git":
+
      case "seed.api":
      case "anchors":
        calls.push(
          iface.encodeFunctionData("setText", [node, "eth.radicle." + r.name, r.value])
modified src/config.json
@@ -75,7 +75,8 @@
    "seed": {
      "host": "0.0.0.0",
      "api": { "port": 8777 },
-
      "link": { "port": 8776 }
+
      "link": { "port": 8776 },
+
      "git": { "port": 80 }
    }
  },
  "ipfs": { "gateway": "https://ipfs.io/ipfs/" },
modified src/config.ts
@@ -7,6 +7,7 @@ import { Core } from '@self.id/core';
import WalletConnect from "@walletconnect/client";
import config from "@app/config.json";
import { WalletConnectSigner } from "./WalletConnectSigner";
+
import type { Seed } from "@app/base/registrations/registrar";

declare global {
  interface Window {
@@ -56,10 +57,9 @@ export class Config {
  };
  abi: { [contract: string]: string[] };
  seed: {
-
    host?: string;
-
    id?: string;
-
    api: { port: number };
-
    link: { port: number };
+
    api: { host?: string; port: number };
+
    git: { host?: string; port: number };
+
    link: { host?: string; id?: string; port: number };
  };
  ceramic: {
   client: Core;
@@ -138,14 +138,20 @@ export class Config {
  }

  // Return the config with an overwritten seed URL.
-
  withSeed(seedHost: string, seedId?: string): Config {
+
  withSeed(seed: Seed): Config {
    const cfg = {} as Config;
    Object.assign(cfg, this);
-
    cfg.seed.host = seedHost;

-
    if (seedId) {
-
      cfg.seed.id = seedId;
-
    }
+
    // The `git` and `api` keys being more specific take
+
    // precedence over the `host`, if available.
+
    const api = seed.api ?? seed.host;
+
    const git = seed.git ?? seed.host;
+

+
    cfg.seed.api.host = api;
+
    cfg.seed.git.host = git;
+
    cfg.seed.link.host = seed.host;
+
    cfg.seed.link.id = seed.id;
+

    return cfg;
  }

modified src/profile.ts
@@ -1,4 +1,4 @@
-
import type { EnsProfile } from "@app/base/registrations/registrar";
+
import type { EnsProfile, Seed } from "@app/base/registrations/registrar";
import type { BasicProfile } from '@datamodels/identity-profile-basic';
import {
  isAddress, formatCAIP10Address, formatIpfsFile, resolveEnsProfile, resolveIdxProfile, parseUsername, parseEnsLabel
@@ -39,52 +39,48 @@ export class Profile {
    return this.profile.idx;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get github(): string | undefined {
    if (this.profile?.ens?.github) return parseUsername(this.profile.ens.github);
    else if (this.profile?.idx?.affiliations) return this.profile.idx?.affiliations.find(item => item === "github");
    else return undefined;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get twitter(): string | undefined {
    if (this.profile?.ens?.twitter) return parseUsername(this.profile.ens.twitter);
    else if (this.profile?.idx?.affiliations) return this.profile.idx.affiliations.find(item => item === "twitter");
    else return undefined;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get url(): string | undefined {
    if (this.profile?.ens?.url) return this.profile.ens.url;
    else if (this.profile?.idx?.url) return this.profile.idx.url;
    else return undefined;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get name(): string | undefined {
    if (this.profile?.ens?.name) return this.profile.ens.name;
    else if (this.profile?.idx?.name) return this.profile.idx.name;
    else return undefined;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get avatar(): string | undefined {
    if (this.profile?.ens?.avatar) return this.profile.ens.avatar;
    else if (this.profile?.idx?.image?.original?.src) return formatIpfsFile(this.profile.idx.image.original.src);
    else return undefined;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get seedHost(): string | undefined {
-
    return this.profile?.ens?.seedHost ?? undefined;
+
    return this.profile?.ens?.seed?.host;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get seedId(): string | undefined {
-
    return this.profile?.ens?.seedId ?? undefined;
+
    return this.profile?.ens?.seed.id;
+
  }
+

+
  get seed(): Seed | undefined {
+
    return this.profile?.ens?.seed;
  }

-
  // Using undefined as return type if nothing to be returned since it works better with <a href> links
  get anchorsAccount(): string | undefined {
    const addr = this.profile?.ens?.anchorsAccount;

@@ -111,8 +107,8 @@ export class Profile {
  // Return the profile-specific config. This sets various URLs in the config,
  // based on profile data.
  config(config: Config): Config {
-
    if (this.seedHost) {
-
      return config.withSeed(this.seedHost, this.seedId);
+
    if (this.seed) {
+
      return config.withSeed(this.seed);
    }
    return config;
  }
@@ -152,7 +148,7 @@ export class Profile {
        return { address, idx: idx ?? undefined };
      } catch (e) {
        // Look for the No DID found for error by the resolveIdxProfile fn and send it to console.debug
-
        if (e.message.match("No DID found for")) console.debug(e);
+
        if (e.message.match("No DID found for")) console.debug(e.message);
        else console.error(e);

        return { address };
modified src/utils.ts
@@ -7,7 +7,7 @@ import type { Config } from '@app/config';
import config from "@app/config.json";
import { assert } from '@app/error';
import type { EnsProfile } from "@app/base/registrations/registrar";
-
import { getAvatar, getSeedHost, getSeedId, getAnchorsAccount, getRegistration } from '@app/base/registrations/registrar';
+
import { getAvatar, getSeed, getAnchorsAccount, getRegistration } from '@app/base/registrations/registrar';
import type { BasicProfile } from '@datamodels/identity-profile-basic';
import { ProfileType } from '@app/profile';
import { parseUnits } from "@ethersproject/units";
@@ -307,7 +307,7 @@ export async function resolveEnsProfile(addressOrName: string, profileType: Prof
        return registration.profile;
      }
    } else {
-
      const promises = [
+
      const promises: [Promise<any>] = [
        getAvatar(name, config, resolver),
      ];

@@ -318,28 +318,24 @@ export async function resolveEnsProfile(addressOrName: string, profileType: Prof
      }

      if (profileType === ProfileType.Project) {
-
        promises.push(getSeedHost(name, config, resolver));
-
        promises.push(getSeedId(name, config, resolver));
+
        promises.push(getSeed(name, config, resolver));
        promises.push(getAnchorsAccount(name, config, resolver));
      } else if (profileType === ProfileType.Minimal) {
        promises.push(Promise.resolve(null));
      }

      const project = await Promise.allSettled(promises);
-
      const [avatar, address, seedHost, seedId, anchorsAccount] =
-
        // Just checking for r.value equal null and casting to undefined, since resolver functions return null
-
        project.map(r => r.status == "fulfilled" ? r.value : null);
+
      const [avatar, address, seed, anchorsAccount] =
+
        // Just checking for r.value equal null and casting to undefined,
+
        // since resolver functions return null.
+
        project.map(r => r.status == "fulfilled" && r.value ? r.value : null);

      return {
        name,
        avatar,
        address,
-
        seedHost,
-
        seedId,
+
        seed,
        anchorsAccount,
-
        url: null,
-
        twitter: null,
-
        github: null,
      };
    }
  }
@@ -393,6 +389,11 @@ export function isMarkdownPath(path: string): boolean {
  return /\.(md|mkd|markdown)$/i.test(path);
}

+
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
+
export function isDomain(input: string): boolean {
+
  return /^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input);
+
}
+

// Propose a Gnosis Safe multi-sig transaction.
export async function proposeSafeTransaction(
  safeTx: SafeTransaction,