about summary refs log tree commit diff
path: root/kittybox-rs/companion-lite/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-10-02 11:54:30 +0300
committerVika <vika@fireburn.ru>2022-10-02 11:54:30 +0300
commit568f98589b6c30bb3f807517d28039b12dd54be3 (patch)
tree885f918885b246d3760cf3b1f2a252b88485f358 /kittybox-rs/companion-lite/src
parentb7d4e5c4686bc8aac41d832567190002542a1743 (diff)
companion-lite: rewrite to use IndieAuth
This is a naive implementation that doesn't have some security
checks. It's ok tho, should work fine... can refine it later
Diffstat (limited to 'kittybox-rs/companion-lite/src')
-rw-r--r--kittybox-rs/companion-lite/src/base64.ts89
-rw-r--r--kittybox-rs/companion-lite/src/indieauth.ts113
-rw-r--r--kittybox-rs/companion-lite/src/main.ts213
-rw-r--r--kittybox-rs/companion-lite/src/micropub_api.ts125
4 files changed, 451 insertions, 89 deletions
diff --git a/kittybox-rs/companion-lite/src/base64.ts b/kittybox-rs/companion-lite/src/base64.ts
new file mode 100644
index 0000000..2429894
--- /dev/null
+++ b/kittybox-rs/companion-lite/src/base64.ts
@@ -0,0 +1,89 @@
+// Array of bytes to Base64 string decoding
+function b64ToUint6(nChr: number) {
+  return nChr > 64 && nChr < 91
+    ? nChr - 65
+    : nChr > 96 && nChr < 123
+    ? nChr - 71
+    : nChr > 47 && nChr < 58
+    ? nChr + 4
+    : nChr === 43
+    ? 62
+    : nChr === 47
+    ? 63
+    : 0;
+}
+
+export function decode(sBase64: string, nBlocksSize?: number) {
+  const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, "");
+  const nInLen = sB64Enc.length;
+  const nOutLen = nBlocksSize
+    ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
+    : (nInLen * 3 + 1) >> 2;
+  const taBytes = new Uint8Array(nOutLen);
+
+  let nMod3;
+  let nMod4;
+  let nUint24 = 0;
+  let nOutIdx = 0;
+  for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
+    nMod4 = nInIdx & 3;
+    nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4));
+    if (nMod4 === 3 || nInLen - nInIdx === 1) {
+      nMod3 = 0;
+      while (nMod3 < 3 && nOutIdx < nOutLen) {
+        taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
+        nMod3++;
+        nOutIdx++;
+      }
+      nUint24 = 0;
+    }
+  }
+
+  return taBytes;
+}
+
+/* Base64 string to array encoding */
+function uint6ToB64(nUint6: number) {
+  return nUint6 < 26
+    ? nUint6 + 65
+    : nUint6 < 52
+    ? nUint6 + 71
+    : nUint6 < 62
+    ? nUint6 - 4
+    : nUint6 === 62
+    ? 43
+    : nUint6 === 63
+    ? 47
+    : 65;
+}
+
+export function encode(aBytes: Uint8Array) {
+  let nMod3 = 2;
+  let sB64Enc = "";
+
+  const nLen = aBytes.length;
+  let nUint24 = 0;
+  for (let nIdx = 0; nIdx < nLen; nIdx++) {
+    nMod3 = nIdx % 3;
+    if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
+      sB64Enc += "\r\n";
+    }
+
+    nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);
+    if (nMod3 === 2 || aBytes.length - nIdx === 1) {
+      sB64Enc += String.fromCodePoint(
+        uint6ToB64((nUint24 >>> 18) & 63),
+        uint6ToB64((nUint24 >>> 12) & 63),
+        uint6ToB64((nUint24 >>> 6) & 63),
+        uint6ToB64(nUint24 & 63)
+      );
+      nUint24 = 0;
+    }
+  }
+  return (
+    sB64Enc.substr(0, sB64Enc.length - 2 + nMod3) +
+      (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==")
+  );
+}
+
+export default { encode, decode }
diff --git a/kittybox-rs/companion-lite/src/indieauth.ts b/kittybox-rs/companion-lite/src/indieauth.ts
new file mode 100644
index 0000000..40facab
--- /dev/null
+++ b/kittybox-rs/companion-lite/src/indieauth.ts
@@ -0,0 +1,113 @@
+// @ts-ignore
+import { mf2 } from "https://esm.sh/microformats-parser@1.4.1?pin=v96"
+import { MF2 } from "./micropub_api.js"
+import base64 from "./base64.js"
+  /*
+  const { mf2 }: {
+    mf2: (html: string, options: {
+      baseUrl: string,
+      experimental?: { lang?: boolean, textContent?: boolean }
+    }) => {
+      items: MF2[],
+      rels: {[key: string]: string[]},
+      "rel-urls": {[key: string]: { rels: string[], text?: string }}
+    }
+  } =
+    // @ts-ignore
+    await import("https://esm.sh/microformats-parser@1.4.1?pin=v96");
+  */
+
+interface IndieauthMetadata {
+  authorization_endpoint: string,
+  token_endpoint: string,
+  issuer: string,
+  introspection_endpoint?: string,
+  introspection_endpoint_auth_methods_supported?: ("Bearer")[],
+  revocation_endpoint?: string,
+  revocation_endpoint_auth_methods_supported?: ["none"],
+  scopes_supported?: string[],
+  response_types_supported: ["code"],
+  grant_types_supported: ("authorization_code" | "refresh_token")[]
+  code_challenge_methods_supported: ("S256")[]
+  authorization_response_iss_parameter_supported: true,
+  userinfo_endpoint?: string
+}
+
+interface MF2ParsedData {
+  items: MF2[],
+  rels: {[key: string]: string[]},
+  "rel-urls": {[key: string]: { rels: string[], text?: string }}
+}
+
+export interface IndiewebEndpoints {
+  authorization_endpoint: URL,
+  token_endpoint: URL,
+  userinfo_endpoint: URL | null,
+  revocation_endpoint: URL | null,
+  micropub: URL,
+  
+}
+
+export function create_verifier() {
+  const array = new Uint8Array(64)
+  crypto.getRandomValues(array)
+
+  return array.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
+}
+
+export async function create_challenge(verifier: string): Promise<string> {
+  return await crypto.subtle.digest('SHA-256', Uint8Array.from(verifier, c => c.charCodeAt(0)))
+    .then((buf) => base64.encode(new Uint8Array(buf)))
+    .then(s => {
+      return s
+        .replaceAll("+", "-")
+        .replaceAll("/", "_")
+        .replaceAll(/=$/g, "")
+    })
+}
+
+export async function discover_endpoints(me: URL): Promise<IndiewebEndpoints | null> {
+  const response = await fetch(me);
+  const data: MF2ParsedData = mf2(await response.text(), { baseUrl: me.toString() });
+  let endpoints: Partial<IndiewebEndpoints> = {};
+  if ("micropub" in data.rels) {
+    endpoints.micropub = new URL(data.rels.micropub[0])
+  } else {
+    return null
+  }
+  if ("indieauth_metadata" in data.rels) {
+    const metadata_response = await fetch(data.rels.indieauth_metadata[0], {
+      headers: {
+        "Accept": "application/json"
+      }
+    });
+
+    const metadata = await metadata_response.json() as IndieauthMetadata;
+    endpoints.authorization_endpoint = new URL(metadata.authorization_endpoint)
+    endpoints.token_endpoint = new URL(metadata.token_endpoint)
+    if (metadata.userinfo_endpoint != null) {
+      endpoints.userinfo_endpoint = new URL(metadata.userinfo_endpoint)
+    } else {
+      endpoints.userinfo_endpoint = null
+    }
+    if (metadata.revocation_endpoint != null) {
+      endpoints.revocation_endpoint = new URL(metadata.revocation_endpoint)
+    } else {
+      endpoints.revocation_endpoint = null
+    }
+
+    return endpoints as IndiewebEndpoints
+  } else if (
+    "authorization_endpoint" in data.rels
+      && "token_endpoint" in data.rels
+  ) {
+    endpoints.authorization_endpoint = new URL(data.rels.authorization_endpoint[0])
+    endpoints.token_endpoint = new URL(data.rels.token_endpoint[0])
+    endpoints.userinfo_endpoint = null
+    endpoints.revocation_endpoint = null
+
+    return endpoints as IndiewebEndpoints
+  } else {
+    return null
+  }
+}
diff --git a/kittybox-rs/companion-lite/src/main.ts b/kittybox-rs/companion-lite/src/main.ts
index d593188..f45cb95 100644
--- a/kittybox-rs/companion-lite/src/main.ts
+++ b/kittybox-rs/companion-lite/src/main.ts
@@ -1,16 +1,30 @@
-import { query_channels, submit, MicropubChannel, MF2 } from "./micropub_api.js";
+import { Micropub, MicropubChannel, MF2 } from "./micropub_api.js";
 
-function get_token() {
-  return (form.elements.namedItem("access_token") as HTMLInputElement).value
-}
-
-const form = document.getElementById("micropub") as HTMLFormElement;
 const channel_select_radio = document.getElementById("select_channels") as HTMLInputElement;
-
 channel_select_radio.onclick = async () => {
-  const channels = await query_channels(form.action, get_token())
-  if (channels !== undefined) {
-    populate_channel_list(channels)
+  function populate_channel_list(channels: MicropubChannel[]) {
+    (document.getElementById("channels") as HTMLElement).style.display = "block";
+    const channel_list = document.getElementById("channels_target") as HTMLElement;
+    channel_list.innerHTML = "";
+    channels.forEach((channel) => {
+      const template = (document.getElementById("channel_selector") as HTMLTemplateElement).content.cloneNode(true) as HTMLElement;
+      const input = template.querySelector("input") as HTMLInputElement;
+      const label = template.querySelector("label") as HTMLLabelElement;
+      input.id = `channel_selector_option_${channel.uid}`
+      input.value = channel.uid
+      label.htmlFor = input.id
+      label.innerHTML = `<a href="${channel.uid}">${channel.name}</a>`
+
+      channel_list.appendChild(template)
+    })
+  }
+
+  if (micropub == null) {
+    throw new Error("need to authenticate first");
+  }
+  const config = await micropub.config();
+  if (config.channels !== undefined) {
+    populate_channel_list(config.channels)
   }
 }
 
@@ -21,65 +35,144 @@ no_channel_radio.onclick = () => {
   channel_list.innerHTML = "";
 }
 
-function construct_body(form: HTMLFormElement): MF2 {
-  let content = (form.elements.namedItem("content") as HTMLInputElement).value;
-  let name: string | undefined = (form.elements.namedItem("name") as HTMLInputElement).value || undefined;
-  let category: string[] = (form.elements.namedItem("category") as HTMLInputElement).value
-    .split(",")
-    .map(val => val.trim());
-
-  let channel: string[] | undefined = undefined;
-  let channel_select = (form.elements.namedItem("channel_select") as HTMLInputElement).value;
-  if (channel_select) {
-    let channel_selector = form.elements.namedItem("channel");
-    if (channel_selector instanceof RadioNodeList) {
-      channel = (Array.from(channel_selector) as HTMLInputElement[])
-        .map(i => i.checked ? i.value : false)
-        .filter(i => i) as string[];
-    } else if (channel_selector instanceof HTMLInputElement) {
-      channel = [channel_selector.value]
+const main_form = document.getElementById("micropub") as HTMLFormElement;
+main_form.onsubmit = async (event) => {
+  function construct_body(form: HTMLFormElement): MF2 {
+    let content = (form.elements.namedItem("content") as HTMLInputElement).value;
+    let name: string | undefined = (form.elements.namedItem("name") as HTMLInputElement).value || undefined;
+    let category: string[] = (form.elements.namedItem("category") as HTMLInputElement).value
+      .split(",")
+      .map(val => val.trim());
+
+    let channel: string[] | undefined = undefined;
+    let channel_select = (form.elements.namedItem("channel_select") as HTMLInputElement).value;
+    if (channel_select) {
+      let channel_selector = form.elements.namedItem("channel");
+      if (channel_selector instanceof RadioNodeList) {
+        channel = (Array.from(channel_selector) as HTMLInputElement[])
+          .map(i => i.checked ? i.value : false)
+          .filter(i => i) as string[];
+      } else if (channel_selector instanceof HTMLInputElement) {
+        channel = [channel_selector.value]
+      }
     }
-  }
-  return {
-    type: ["h-entry"],
-    properties: {
-      content: [content],
-      name: name ? [name] : undefined,
-      category: category.length ? category : undefined,
-      channel: channel ? channel : undefined
+    return {
+      type: ["h-entry"],
+      properties: {
+        content: [content],
+        name: name ? [name] : undefined,
+        category: category.length ? category : undefined,
+        channel: channel ? channel : undefined
+      }
     }
   }
-}
 
-function populate_channel_list(channels: MicropubChannel[]) {
-  (document.getElementById("channels") as HTMLElement).style.display = "block";
-  const channel_list = document.getElementById("channels_target") as HTMLElement;
-  channel_list.innerHTML = "";
-  channels.forEach((channel) => {
-    const template = (document.getElementById("channel_selector") as HTMLTemplateElement).content.cloneNode(true) as HTMLElement;
-    const input = template.querySelector("input") as HTMLInputElement;
-    const label = template.querySelector("label") as HTMLLabelElement;
-    input.id = `channel_selector_option_${channel.uid}`
-    input.value = channel.uid
-    label.htmlFor = input.id
-    label.innerHTML = `<a href="${channel.uid}">${channel.name}</a>`
-
-    channel_list.appendChild(template)
-  })
-}
-
-form.onsubmit = async (event) => {
   event.preventDefault()
-  const mf2 = construct_body(form);
+  const mf2 = construct_body(main_form);
   console.log(JSON.stringify(mf2));
+  if (micropub == null) {
+    throw new Error("need to authenticate first");
+  }
   try {
-    submit(form.action, get_token(), mf2)
-  } catch (e) {
-    // TODO show errors to user
+    const location = await micropub.submit(mf2);
+    main_form.clear()
 
+    window.open(location, "_blank")
+  } catch (e) {
+    console.error(e)
+    alert(`Error: ${e}`)
     return
   }
-  form.clear()
+
+}
+
+const indieauth_form = document.getElementById("indieauth") as HTMLFormElement;
+indieauth_form.onsubmit = async (event) => {
+  event.preventDefault()
+  const form = event.target as HTMLFormElement;
+  const me = (form.elements.namedItem("me") as HTMLInputElement).value;
+  if (me != null) {
+    const { discover_endpoints, create_verifier, create_challenge } = await import("./indieauth.js");
+
+    const endpoints = await discover_endpoints(new URL(me));
+
+    if (endpoints != null) {
+      localStorage.setItem("micropub_endpoint", endpoints.micropub.toString())
+      localStorage.setItem("token_endpoint", endpoints.token_endpoint.toString())
+      if (endpoints.revocation_endpoint != null) {
+        localStorage.setItem("revocation_endpoint", endpoints.revocation_endpoint.toString())
+      }
+    } else {
+      alert("Your website doesn't support Micropub.")
+      return
+    }
+    (document.getElementById("unauthorized") as HTMLElement).style.display = "none";
+    (document.getElementById("authorizing") as HTMLElement).style.display = "block";
+    const url = endpoints.authorization_endpoint;
+    let params = new URLSearchParams();
+    for (const [key, val] of url.searchParams) {
+      params.append(key, val)
+    }
+    params.set("client_id", window.location.href)
+    params.set("redirect_uri", window.location.href)
+    params.set("response_type", "code")
+    params.set("scope", "profile create media")
+    params.set("state", "awoo")
+    const code_verifier = create_verifier()
+    localStorage.setItem("code_verifier", code_verifier)
+    params.set("code_challenge", await create_challenge(code_verifier))
+    params.set("code_challenge_method", "S256")
+
+    url.search = "?" + params.toString()
+
+    console.log(url)
+
+    window.location.href = url.toString()
+  }
 }
 
-(document.getElementById("authorized") as HTMLElement).style.display = "block";
+if (window.location.search != "") {
+  (document.getElementById("authorizing") as HTMLElement).style.display = "block";
+  const params = new URLSearchParams(window.location.search)
+  if (params.has("code") && params.has("state")) {
+    const token_endpoint = new URL(localStorage.getItem("token_endpoint")!)
+    const state = params.get("state")
+    // XXX check state
+
+    const client_id = new URL(window.location.href);
+    client_id.search = "";
+    const form = new URLSearchParams();
+    form.set("grant_type", "authorization_code")
+    form.set("code", params.get("code")!)
+    form.set("client_id", client_id.toString())
+    form.set("redirect_uri", client_id.toString())
+    form.set("code_verifier", localStorage.getItem("code_verifier")!)
+
+    const response = await fetch(token_endpoint, {
+      method: "POST",
+      headers: {
+        "Accept": "application/json",
+        "Content-Type": "application/x-www-form-urlencoded"
+      },
+      body: form.toString()
+    });
+
+    const grant = await response.json();
+
+    if ("access_token" in grant) {
+      localStorage.setItem("access_token", grant.access_token);
+      (document.getElementById("authorizing") as HTMLElement).style.display = "none";
+    }
+  }
+}
+
+let micropub: Micropub | null = null;
+const token = localStorage.getItem("access_token")
+const endpoint = localStorage.getItem("micropub_endpoint")
+if (token == null || endpoint == null) {
+  (document.getElementById("unauthorized") as HTMLElement).style.display = "block";
+} else {
+  (document.getElementById("authorized") as HTMLElement).style.display = "block";
+
+  micropub = new Micropub({ endpoint: new URL(endpoint), token });
+}
diff --git a/kittybox-rs/companion-lite/src/micropub_api.ts b/kittybox-rs/companion-lite/src/micropub_api.ts
index 9eb65a2..fa1c431 100644
--- a/kittybox-rs/companion-lite/src/micropub_api.ts
+++ b/kittybox-rs/companion-lite/src/micropub_api.ts
@@ -1,6 +1,6 @@
 export interface MicropubChannel {
-  uid: string,
-  name: string
+  readonly uid: string,
+  readonly name: string
 }
 
 export interface MF2 {
@@ -9,50 +9,117 @@ export interface MF2 {
 }
 
 export interface MicropubConfig {
-  channels: MicropubChannel[],
-  "media-endpoint": string
+  readonly channels?: MicropubChannel[],
+  readonly "media-endpoint"?: string
 }
 
-export async function query_channels(endpoint: string, token: string): Promise<MicropubChannel[]> {
-  const response = await fetch(endpoint + "?q=config", {
-    headers: {
-      "Authorization": `Bearer ${token}`
-    }
-  })
+export interface MicropubErrorMessage {
+  readonly error: string,
+  readonly error_description: string | undefined
+}
+
+export class MicropubError extends Error {
+  readonly status: number | null
+  readonly response: MicropubErrorMessage | null
 
-  if (response.ok) {
-    const config = await response.json() as MicropubConfig;
+  constructor(status: number | null, response: MicropubErrorMessage | null, cause: Error | null = null) {
+    // Needs to pass both `message` and `options` to install the "cause" property.
+    if (status == null) {
+      super("Micropub endpoint didn't respond properly", { cause });
+    } else if (response == null) {
+      super(`Micropub endpoint returned HTTP ${status}`, { cause });
+    } else {
+      super(
+        `Micropub endpoint returned ${response.error}: ${response.error_description ?? "(no description was provided)"}`,
+        { cause }
+      )
+    }
 
-    return config["channels"]
-  } else {
-    throw new Error(`Micropub endpoint returned ${response.status}: ${await response.json()}`)
+    this.status = status;
+    this.response = response;
   }
-  
 }
 
-export async function submit(endpoint: string, token: string, mf2: MF2) {
-  try {
-    const response = await fetch(endpoint, {
+export class Micropub {
+  readonly token: string
+  readonly micropub_endpoint: URL
+  private config_response: MicropubConfig | null
+  
+  constructor({ endpoint, token }: { endpoint: URL, token: string }) {
+    this.micropub_endpoint = endpoint;
+    this.token = token;
+    this.config_response = null;
+  }
+
+  async config(): Promise<MicropubConfig> {
+    if (this.config_response != null) {
+      return this.config_response
+    }
+    let url = this.micropub_endpoint;
+    let params = new URLSearchParams();
+    for (const [key, val] of url.searchParams) {
+      params.append(key, val)
+    }
+    params.set("q", "config")
+
+    url.search = "?" + params.toString();
+
+    const response = await fetch(url, {
+      headers: {
+        "Authorization": `Bearer ${this.token}`
+      }
+    });
+    if (response.ok) {
+      const config = await response.json() as MicropubConfig;
+      this.config_response = config
+
+      return config
+    } else {
+      throw new MicropubError(response.status, await response.json() as MicropubErrorMessage);
+    }
+  }
+
+  async submit(mf2: MF2): Promise<URL> {
+    const response = await fetch(this.micropub_endpoint, {
       method: "POST",
       headers: {
-        "Authorization": `Bearer ${token}`,
+        "Authorization": `Bearer ${this.token}`,
         "Content-Type": "application/json"
       },
       body: JSON.stringify(mf2)
     })
 
     if (response.status != 201 && response.status != 202) {
-      let err = await response.json();
-      console.error("Micropub error!", err);
+      let err = await response.json() as MicropubErrorMessage;
 
-      return err;
+      throw new MicropubError(response.status, err)
     } else {
-      return {
-        "location": response.headers.get("Location")
-      }
+      return new URL(response.headers.get("Location") as string)
+    }
+  }
+
+  async upload(file: File): Promise<URL> {
+    const config = await this.config();
+    const media = config["media-endpoint"];
+    if (media == null) {
+      throw new Error("Micropub endpoint doesn't support file uploads")
+    }
+
+    const form = new FormData();
+    form.set("file", file);
+
+    const response = await fetch(media, {
+      method: "POST",
+      headers: {
+        "Authorization": `Bearer ${this.token}`,
+      },
+      body: form
+    })
+
+    if (response.ok) {
+      return new URL(response.headers.get("Location") as string)
+    } else {
+      throw new MicropubError(response.status, await response.json());
     }
-  } catch (e) {
-    console.error("Network error!", e)
-    throw e
   }
 }