1 // Copyright (c) 2024 by Juliusz Chroboczek.
  2 
  3 // Permission is hereby granted, free of charge, to any person obtaining a copy
  4 // of this software and associated documentation files (the "Software"), to deal
  5 // in the Software without restriction, including without limitation the rights
  6 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  7 // copies of the Software, and to permit persons to whom the Software is
  8 // furnished to do so, subject to the following conditions:
  9 //
 10 // The above copyright notice and this permission notice shall be included in
 11 // all copies or substantial portions of the Software.
 12 //
 13 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 14 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 15 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
 16 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 17 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 18 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 19 // THE SOFTWARE.
 20 
 21 'use strict';
 22 
 23 /**
 24  * httpError returns an error that encapsulates the status of the response r.
 25  *
 26  * @param {Response} r
 27  * @returns {Error}
 28  */
 29 function httpError(r) {
 30     let s = r.statusText;
 31     if(s === '') {
 32         switch(r.status) {
 33             case 401: s = 'Unauthorised'; break;
 34             case 403: s = 'Forbidden'; break;
 35             case 404: s = 'Not Found'; break;
 36         }
 37     }
 38 
 39     return new Error(`The server said: ${r.status} ${s}`);
 40 }
 41 
 42 /**
 43  * listObjects fetches a list of strings from the given URL.
 44  *
 45  * @param {string} url
 46  * @returns {Promise<Array<string>>}
 47  */
 48 async function listObjects(url) {
 49     let r = await fetch(url);
 50     if(!r.ok)
 51         throw httpError(r);
 52     let strings = (await r.text()).split('\n');
 53     if(strings[strings.length - 1] === '') {
 54         strings.pop();
 55     }
 56     return strings;
 57 }
 58 
 59 /**
 60  * createObject makes a PUT request to url with JSON data.
 61  * It fails if the object already exists.
 62  *
 63  * @param {string} url
 64  * @param {Object} [values]
 65  */
 66 async function createObject(url, values) {
 67     if(!values)
 68         values = {};
 69     let r = await fetch(url, {
 70         method: 'PUT',
 71         body: JSON.stringify(values),
 72         headers: {
 73             'If-None-Match': '*',
 74             'Content-Type:': 'application/json',
 75         }
 76     });
 77     if(!r.ok)
 78         throw httpError(r);
 79 }
 80 
 81 /**
 82  * getObject fetches the JSON object at a given URL.
 83  * If an ETag is provided, it fails if the ETag didn't match.
 84  *
 85  * @param {string} url
 86  * @param {string} [etag]
 87  * @returns {Promise<Object>}
 88  */
 89 async function getObject(url, etag) {
 90     let options = {};
 91     if(etag) {
 92         options.headers = {
 93             'If-Match': etag
 94         }
 95     }
 96     let r = await fetch(url, options);
 97     if(!r.ok)
 98         throw httpError(r);
 99     let newetag = r.headers.get("ETag");
100     if(!newetag)
101         throw new Error("The server didn't return an ETag");
102     if(etag && newetag !== etag)
103         throw new Error("The server returned a mismatched ETag");
104     let data = await r.json();
105     return {etag: newetag, data: data}
106 }
107 
108 /**
109  * deleteObject makes a DELETE request to the given URL.
110  * If an ETag is provided, it fails if the ETag didn't match.
111  *
112  * @param {string} url
113  * @param {string} [etag]
114  */
115 async function deleteObject(url, etag) {
116     /** @type {Object<string, string>} */
117     let headers = {};
118     if(etag)
119         headers['If-Match'] = etag;
120     let r = await fetch(url, {
121         method: 'DELETE',
122         headers: headers,
123     });
124     if(!r.ok)
125         throw httpError(r);
126 }
127 
128 /**
129  * updateObject makes a read-modify-write cycle on the given URL.  Any
130  * fields that are non-null in values are added or modified, any fields
131  * that are null are deleted, any fields that are absent are left unchanged.
132  *
133  * @param {string} url
134  * @param {Object} values
135  * @param {string} [etag]
136  */
137 async function updateObject(url, values, etag) {
138     let old = await getObject(url, etag);
139     let data = old.data;
140     for(let k in values) {
141         if(values[k])
142             data[k] = values[k];
143         else
144             delete(data[k])
145     }
146     let r = await fetch(url, {
147         method: 'PUT',
148         headers: {
149             'Content-Type': 'application/json',
150             'If-Match': old.etag,
151         }
152     })
153     if(!r.ok)
154         throw httpError(r);
155 }
156 
157 /**
158  * listGroups returns the list of groups.
159  *
160  * @returns {Promise<Array<string>>}
161  */
162 async function listGroups() {
163     return await listObjects('/galene-api/0/.groups/');
164 }
165 
166 /**
167  * getGroup returns the sanitised description of the given group.
168  *
169  * @param {string} group
170  * @param {string} [etag]
171  * @returns {Promise<Object>}
172  */
173 async function getGroup(group, etag) {
174     return await getObject(`/galene-api/0/.groups/${group}`, etag);
175 }
176 
177 /**
178  * createGroup creates a group.  It fails if the group already exists.
179  *
180  * @param {string} group
181  * @param {Object} [values]
182  */
183 async function createGroup(group, values) {
184     return await createObject(`/galene-api/0/.groups/${group}`, values);
185 }
186 
187 /**
188  * deleteGroup deletes a group.
189  *
190  * @param {string} group
191  * @param {string} [etag]
192  */
193 async function deleteGroup(group, etag) {
194     return await deleteObject(`/galene-api/0/.groups/${group}`, etag);
195 }
196 
197 /**
198  * updateGroup modifies a group definition.
199  * Any fields present in values are overriden, any fields absent in values
200  * are left unchanged.
201  *
202  * @param {string} group
203  * @param {Object} values
204  * @param {string} [etag]
205  */
206 async function updateGroup(group, values, etag) {
207     return await updateObject(`/galene-api/0/.groups/${group}`, values);
208 }
209 
210 /**
211  * listUsers lists the users in a given group.
212  *
213  * @param {string} group
214  * @returns {Promise<Array<string>>}
215  */
216 async function listUsers(group) {
217     return await listObjects(`/galene-api/0/.groups/${group}/.users/`);
218 }
219 
220 /**
221  * getUser returns a given user entry.
222  *
223  * @param {string} group
224  * @param {string} user
225  * @param {string} [etag]
226  * @returns {Promise<Object>}
227  */
228 async function getUser(group, user, etag) {
229     return await getObject(`/galene-api/0/.groups/${group}/.users/${user}`,
230                            etag);
231 }
232 
233 /**
234  * createUser creates a new user entry.  It fails if the user already
235  * exists.
236  *
237  * @param {string} group
238  * @param {string} user
239  * @param {Object} values
240  */
241 async function createUser(group, user, values) {
242     return await createObject(`/galene-api/0/.groups/${group}/.users/${user}`,
243                               values);
244 }
245 
246 /**
247  * deleteUser deletes a user.
248  *
249  * @param {string} group
250  * @param {string} user
251  * @param {string} [etag]
252  */
253 async function deleteUser(group, user, etag) {
254     return await deleteObject(
255         `/galene-api/0/.groups/${group}/.users/${user}/`, etag,
256     );
257 }
258 
259 /**
260  * updateUser modifies a given user entry.
261  *
262  * @param {string} group
263  * @param {string} user
264  * @param {Object} values
265  * @param {string} [etag]
266  */
267 async function updateUser(group, user, values, etag) {
268     return await updateObject(`/galene-api/0/.groups/${group}/.users/${user}`,
269                             values, etag);
270 }
271 
272 /**
273  * setPassword sets a user's password.
274  * If oldpassword is provided, then it is used for authentication instead
275  * of the browser's normal mechanism.
276  *
277  * @param {string} group
278  * @param {string} user
279  * @param {string} password
280  * @param {string} [oldpassword]
281  */
282 async function setPassword(group, user, password, oldpassword) {
283     let options = {
284         method: 'POST',
285         headers: {
286             'Content-Type': 'text/plain'
287         },
288         body: password,
289     }
290     if(oldpassword) {
291         options.credentials = 'omit';
292         options.headers['Authorization'] =
293             `Basic ${btoa(user + ':' + oldpassword)}`
294     }
295 
296     let r = await fetch(
297         `/galene-api/0/.groups/${group}/.users/${user}/.password`,
298         options);
299     if(!r.ok)
300         throw httpError(r);
301 }
302 
303 /**
304  * listUsers lists the tokens for a given group.
305  *
306  * @param {string} group
307  * @returns {Promise<Array<string>>}
308  */
309 async function listTokens(group) {
310     return await listObjects(`/galene-api/0/.groups/${group}/.tokens/`);
311 }
312 
313 /**
314  * getToken returns a given token.
315  *
316  * @param {string} group
317  * @param {string} token
318  * @param {string} [etag]
319  * @returns {Promise<Object>}
320  */
321 async function getToken(group, token, etag) {
322     return await getObject(`/galene-api/0/.groups/${group}/.tokens/${token}`,
323                            etag);
324 }
325 
326 /**
327  * createToken creates a new token and returns its name
328  *
329  * @param {string} group
330  * @param {Object} template
331  * @returns {Promise<string>}
332  */
333 async function createToken(group, template) {
334     let options = {
335         method: 'POST',
336         headers: {
337             'Content-Type': 'text/json'
338         },
339         body: template,
340     }
341 
342     let r = await fetch(
343         `/galene-api/0/.groups/${group}/.tokens/`,
344         options);
345     if(!r.ok)
346         throw httpError(r);
347     let t = r.headers.get('Location');
348     if(!t)
349         throw new Error("Server didn't return location header");
350     return t;
351 }
352 
353 /**
354  * updateToken modifies a token.
355  *
356  * @param {string} group
357  * @param {Object} token
358  */
359 async function updateToken(group, token, etag) {
360     if(!token.token)
361         throw new Error("Unnamed token");
362     return await updateObject(
363         `/galene-api/0/.groups/${group}/.tokens/${token.token}`,
364         token, etag);
365 }
366