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