about summary refs log tree commit diff
path: root/kittybox-rs/companion-lite/src/micropub_api.ts
blob: fa1c431e31e23f6abdea06132476c4403b2c7247 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
export interface MicropubChannel {
  readonly uid: string,
  readonly name: string
}

export interface MF2 {
  type: string[],
  properties: { [key:string]: (string | MF2 | {[key:string]: string})[] | undefined }
}

export interface MicropubConfig {
  readonly channels?: MicropubChannel[],
  readonly "media-endpoint"?: string
}

export interface MicropubErrorMessage {
  readonly error: string,
  readonly error_description: string | undefined
}

export class MicropubError extends Error {
  readonly status: number | null
  readonly response: MicropubErrorMessage | null

  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 }
      )
    }

    this.status = status;
    this.response = response;
  }
}

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 ${this.token}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(mf2)
    })

    if (response.status != 201 && response.status != 202) {
      let err = await response.json() as MicropubErrorMessage;

      throw new MicropubError(response.status, err)
    } else {
      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());
    }
  }
}