WIP because: - [x] Some calls set a `content-type` but send no body, can likely remove the header - [x] Need to check whether `charset=utf-8` has any significance on the webauthn calls, I assume not as it is the default for json content. - [x] Maybe `no-restricted-globals` is better for eslint, but will require a lot of duplication in the yaml or moving eslint config to a `.js` extension. - [x] Maybe export `request` as `fetch`, shadowing the global.tags/v1.21.0-rc0
@@ -46,6 +46,9 @@ overrides: | |||
- files: ["*.config.*"] | |||
rules: | |||
import/no-unused-modules: [0] | |||
- files: ["web_src/js/modules/fetch.js", "web_src/js/standalone/**/*"] | |||
rules: | |||
no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] | |||
rules: | |||
"@eslint-community/eslint-comments/disable-enable-pair": [2] | |||
@@ -420,7 +423,7 @@ rules: | |||
no-restricted-exports: [0] | |||
no-restricted-globals: [2, addEventListener, blur, close, closed, confirm, defaultStatus, defaultstatus, error, event, external, find, focus, frameElement, frames, history, innerHeight, innerWidth, isFinite, isNaN, length, location, locationbar, menubar, moveBy, moveTo, name, onblur, onerror, onfocus, onload, onresize, onunload, open, opener, opera, outerHeight, outerWidth, pageXOffset, pageYOffset, parent, print, removeEventListener, resizeBy, resizeTo, screen, screenLeft, screenTop, screenX, screenY, scroll, scrollbars, scrollBy, scrollTo, scrollX, scrollY, self, status, statusbar, stop, toolbar, top, __dirname, __filename] | |||
no-restricted-imports: [0] | |||
no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] | |||
no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression, {selector: "CallExpression[callee.name='fetch']", message: "use modules/fetch.js instead"}] | |||
no-return-assign: [0] | |||
no-script-url: [2] | |||
no-self-assign: [2, {props: true}] |
@@ -95,7 +95,7 @@ Some lint rules and IDEs also have warnings if the returned Promise is not handl | |||
### Fetching data | |||
To fetch data, use the wrapper functions `GET`, `POST` etc. from `modules/fetch.js`. They | |||
accept a `data` option for the content, will automatically set CSFR token and return a | |||
accept a `data` option for the content, will automatically set CSRF token and return a | |||
Promise for a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). | |||
### HTML Attributes and `dataset` |
@@ -2,6 +2,7 @@ | |||
import {createApp, nextTick} from 'vue'; | |||
import $ from 'jquery'; | |||
import {SvgIcon} from '../svg.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
const {appSubUrl, assetUrlPrefix, pageData} = window.config; | |||
@@ -233,11 +234,11 @@ const sfc = { | |||
try { | |||
if (!this.reposTotalCount) { | |||
const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; | |||
response = await fetch(totalCountSearchURL); | |||
response = await GET(totalCountSearchURL); | |||
this.reposTotalCount = response.headers.get('X-Total-Count'); | |||
} | |||
response = await fetch(searchedURL); | |||
response = await GET(searchedURL); | |||
json = await response.json(); | |||
} catch { | |||
if (searchedURL === this.searchURL) { |
@@ -1,5 +1,6 @@ | |||
<script> | |||
import {SvgIcon} from '../svg.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
export default { | |||
components: {SvgIcon}, | |||
@@ -123,7 +124,7 @@ export default { | |||
}, | |||
/** Load the commits to show in this dropdown */ | |||
async fetchCommits() { | |||
const resp = await fetch(`${this.issueLink}/commits/list`); | |||
const resp = await GET(`${this.issueLink}/commits/list`); | |||
const results = await resp.json(); | |||
this.commits.push(...results.commits.map((x) => { | |||
x.hovered = false; |
@@ -4,6 +4,7 @@ import $ from 'jquery'; | |||
import {SvgIcon} from '../svg.js'; | |||
import {pathEscapeSegments} from '../utils/url.js'; | |||
import {showErrorToast} from '../modules/toast.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
const sfc = { | |||
components: {SvgIcon}, | |||
@@ -190,8 +191,7 @@ const sfc = { | |||
} | |||
this.isLoading = true; | |||
try { | |||
const reqUrl = `${this.repoLink}/${this.mode}/list`; | |||
const resp = await fetch(reqUrl); | |||
const resp = await GET(`${this.repoLink}/${this.mode}/list`); | |||
const {results} = await resp.json(); | |||
for (const result of results) { | |||
let selected = false; |
@@ -11,6 +11,7 @@ import {htmlEscape} from 'escape-goat'; | |||
import {showTemporaryTooltip} from '../modules/tippy.js'; | |||
import {confirmModal} from './comp/ConfirmModal.js'; | |||
import {showErrorToast} from '../modules/toast.js'; | |||
import {request} from '../modules/fetch.js'; | |||
const {appUrl, appSubUrl, csrfToken, i18n} = window.config; | |||
@@ -81,7 +82,7 @@ function fetchActionDoRedirect(redirect) { | |||
async function fetchActionDoRequest(actionElem, url, opt) { | |||
try { | |||
const resp = await fetch(url, opt); | |||
const resp = await request(url, opt); | |||
if (resp.status === 200) { | |||
let {redirect} = await resp.json(); | |||
redirect = redirect || actionElem.getAttribute('data-redirect'); | |||
@@ -127,7 +128,7 @@ async function formFetchAction(e) { | |||
} | |||
let reqUrl = formActionUrl; | |||
const reqOpt = {method: formMethod.toUpperCase(), headers: {'X-Csrf-Token': csrfToken}}; | |||
const reqOpt = {method: formMethod.toUpperCase()}; | |||
if (formMethod.toLowerCase() === 'get') { | |||
const params = new URLSearchParams(); | |||
for (const [key, value] of formData) { | |||
@@ -264,7 +265,7 @@ async function linkAction(e) { | |||
const url = el.getAttribute('data-url'); | |||
const doRequest = async () => { | |||
el.disabled = true; | |||
await fetchActionDoRequest(el, url, {method: 'POST', headers: {'X-Csrf-Token': csrfToken}}); | |||
await fetchActionDoRequest(el, url, {method: 'POST'}); | |||
el.disabled = false; | |||
}; | |||
@@ -1,7 +1,8 @@ | |||
import $ from 'jquery'; | |||
import {isElemHidden, onInputDebounce, toggleElem} from '../utils/dom.js'; | |||
const {appSubUrl} = window.config; | |||
import {GET} from '../modules/fetch.js'; | |||
const {appSubUrl} = window.config; | |||
const reIssueIndex = /^(\d+)$/; // eg: "123" | |||
const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123" | |||
const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/; // eg: "{owner}/{repo}#{index}" | |||
@@ -54,7 +55,7 @@ export function initCommonIssueListQuickGoto() { | |||
// try to check whether the parsed goto link is valid | |||
let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText); | |||
if (targetUrl) { | |||
const res = await fetch(`${targetUrl}/info`); | |||
const res = await GET(`${targetUrl}/info`); | |||
if (res.status !== 200) targetUrl = ''; | |||
} | |||
@@ -1,16 +1,11 @@ | |||
import $ from 'jquery'; | |||
const {csrfToken} = window.config; | |||
import {POST} from '../../modules/fetch.js'; | |||
async function uploadFile(file, uploadUrl) { | |||
const formData = new FormData(); | |||
formData.append('file', file, file.name); | |||
const res = await fetch(uploadUrl, { | |||
method: 'POST', | |||
headers: {'X-Csrf-Token': csrfToken}, | |||
body: formData, | |||
}); | |||
const res = await POST(uploadUrl, {data: formData}); | |||
return await res.json(); | |||
} | |||
@@ -1,6 +1,5 @@ | |||
import $ from 'jquery'; | |||
const {csrfToken} = window.config; | |||
import {POST} from '../../modules/fetch.js'; | |||
export function initCompReactionSelector($parent) { | |||
$parent.find(`.select-reaction .item.reaction, .comment-reaction-button`).on('click', async function (e) { | |||
@@ -12,15 +11,8 @@ export function initCompReactionSelector($parent) { | |||
const reactionContent = $(this).attr('data-reaction-content'); | |||
const hasReacted = $(this).closest('.ui.segment.reactions').find(`a[data-reaction-content="${reactionContent}"]`).attr('data-has-reacted') === 'true'; | |||
const res = await fetch(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { | |||
method: 'POST', | |||
headers: { | |||
'content-type': 'application/x-www-form-urlencoded', | |||
}, | |||
body: new URLSearchParams({ | |||
_csrf: csrfToken, | |||
content: reactionContent, | |||
}), | |||
const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { | |||
data: new URLSearchParams({content: reactionContent}), | |||
}); | |||
const data = await res.json(); |
@@ -1,6 +1,7 @@ | |||
import {clippie} from 'clippie'; | |||
import {showTemporaryTooltip} from '../modules/tippy.js'; | |||
import {convertImage} from '../utils.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
const {i18n} = window.config; | |||
@@ -20,7 +21,7 @@ export function initCopyContent() { | |||
if (link) { | |||
btn.classList.add('is-loading', 'small-loading-icon'); | |||
try { | |||
const res = await fetch(link, {credentials: 'include', redirect: 'follow'}); | |||
const res = await GET(link, {credentials: 'include', redirect: 'follow'}); | |||
const contentType = res.headers.get('content-type'); | |||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { |
@@ -1,5 +1,6 @@ | |||
import $ from 'jquery'; | |||
import {hideElem, showElem} from '../utils/dom.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
export function initInstall() { | |||
const $page = $('.page-content.install'); | |||
@@ -111,7 +112,7 @@ function initPostInstall() { | |||
const targetUrl = el.getAttribute('href'); | |||
let tid = setInterval(async () => { | |||
try { | |||
const resp = await fetch(targetUrl); | |||
const resp = await GET(targetUrl); | |||
if (tid && resp.status === 200) { | |||
clearInterval(tid); | |||
tid = null; |
@@ -1,7 +1,8 @@ | |||
import {diffTreeStore} from '../modules/stores.js'; | |||
import {setFileFolding} from './file-fold.js'; | |||
import {POST} from '../modules/fetch.js'; | |||
const {csrfToken, pageData} = window.config; | |||
const {pageData} = window.config; | |||
const prReview = pageData.prReview || {}; | |||
const viewedStyleClass = 'viewed-file-checked-form'; | |||
const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found | |||
@@ -68,11 +69,7 @@ export function initViewedCheckboxListenerFor() { | |||
const data = {files}; | |||
const headCommitSHA = form.getAttribute('data-headcommit'); | |||
if (headCommitSHA) data.headCommitSHA = headCommitSHA; | |||
fetch(form.getAttribute('data-link'), { | |||
method: 'POST', | |||
headers: {'X-Csrf-Token': csrfToken}, | |||
body: JSON.stringify(data), | |||
}); | |||
POST(form.getAttribute('data-link'), {data}); | |||
// Fold the file accordingly | |||
const parentBox = form.closest('.diff-file-header'); |
@@ -1,9 +1,10 @@ | |||
import {hideElem, showElem, toggleElem} from '../utils/dom.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
async function loadBranchesAndTags(area, loadingButton) { | |||
loadingButton.classList.add('disabled'); | |||
try { | |||
const res = await fetch(loadingButton.getAttribute('data-fetch-url')); | |||
const res = await GET(loadingButton.getAttribute('data-fetch-url')); | |||
const data = await res.json(); | |||
hideElem(loadingButton); | |||
addTags(area, data.tags); |
@@ -5,6 +5,7 @@ import {htmlEscape} from 'escape-goat'; | |||
import {confirmModal} from './comp/ConfirmModal.js'; | |||
import {showErrorToast} from '../modules/toast.js'; | |||
import {createSortable} from '../modules/sortable.js'; | |||
import {DELETE, POST} from '../modules/fetch.js'; | |||
function initRepoIssueListCheckboxes() { | |||
const $issueSelectAll = $('.issue-checkbox-all'); | |||
@@ -146,13 +147,7 @@ function initPinRemoveButton() { | |||
const id = Number(el.getAttribute('data-issue-id')); | |||
// Send the unpin request | |||
const response = await fetch(el.getAttribute('data-unpin-url'), { | |||
method: 'delete', | |||
headers: { | |||
'X-Csrf-Token': window.config.csrfToken, | |||
'Content-Type': 'application/json', | |||
}, | |||
}); | |||
const response = await DELETE(el.getAttribute('data-unpin-url')); | |||
if (response.ok) { | |||
// Delete the tooltip | |||
el._tippy.destroy(); | |||
@@ -166,14 +161,7 @@ function initPinRemoveButton() { | |||
async function pinMoveEnd(e) { | |||
const url = e.item.getAttribute('data-move-url'); | |||
const id = Number(e.item.getAttribute('data-issue-id')); | |||
await fetch(url, { | |||
method: 'post', | |||
body: JSON.stringify({id, position: e.newIndex + 1}), | |||
headers: { | |||
'X-Csrf-Token': window.config.csrfToken, | |||
'Content-Type': 'application/json', | |||
}, | |||
}); | |||
await POST(url, {data: {id, position: e.newIndex + 1}}); | |||
} | |||
async function initIssuePinSort() { |
@@ -1,7 +1,8 @@ | |||
import $ from 'jquery'; | |||
import {hideElem, showElem} from '../utils/dom.js'; | |||
import {GET, POST} from '../modules/fetch.js'; | |||
const {appSubUrl, csrfToken} = window.config; | |||
const {appSubUrl} = window.config; | |||
export function initRepoMigrationStatusChecker() { | |||
const $repoMigrating = $('#repo_migrating'); | |||
@@ -13,7 +14,7 @@ export function initRepoMigrationStatusChecker() { | |||
// returns true if the refresh still need to be called after a while | |||
const refresh = async () => { | |||
const res = await fetch(`${appSubUrl}/user/task/${task}`); | |||
const res = await GET(`${appSubUrl}/user/task/${task}`); | |||
if (res.status !== 200) return true; // continue to refresh if network error occurs | |||
const data = await res.json(); | |||
@@ -58,12 +59,6 @@ export function initRepoMigrationStatusChecker() { | |||
} | |||
async function doMigrationRetry(e) { | |||
await fetch($(e.target).attr('data-migrating-task-retry-url'), { | |||
method: 'post', | |||
headers: { | |||
'X-Csrf-Token': csrfToken, | |||
'Content-Type': 'application/json', | |||
}, | |||
}); | |||
await POST($(e.target).attr('data-migrating-task-retry-url')); | |||
window.location.reload(); | |||
} |
@@ -1,7 +1,8 @@ | |||
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js'; | |||
import {showElem} from '../utils/dom.js'; | |||
import {GET, POST} from '../modules/fetch.js'; | |||
const {appSubUrl, csrfToken} = window.config; | |||
const {appSubUrl} = window.config; | |||
export async function initUserAuthWebAuthn() { | |||
const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); | |||
@@ -13,7 +14,7 @@ export async function initUserAuthWebAuthn() { | |||
return; | |||
} | |||
const res = await fetch(`${appSubUrl}/user/webauthn/assertion`); | |||
const res = await GET(`${appSubUrl}/user/webauthn/assertion`); | |||
if (res.status !== 200) { | |||
webAuthnError('unknown'); | |||
return; | |||
@@ -53,12 +54,8 @@ async function verifyAssertion(assertedCredential) { | |||
const sig = new Uint8Array(assertedCredential.response.signature); | |||
const userHandle = new Uint8Array(assertedCredential.response.userHandle); | |||
const res = await fetch(`${appSubUrl}/user/webauthn/assertion`, { | |||
method: 'POST', | |||
headers: { | |||
'Content-Type': 'application/json; charset=utf-8' | |||
}, | |||
body: JSON.stringify({ | |||
const res = await POST(`${appSubUrl}/user/webauthn/assertion`, { | |||
data: { | |||
id: assertedCredential.id, | |||
rawId: encodeURLEncodedBase64(rawId), | |||
type: assertedCredential.type, | |||
@@ -69,7 +66,7 @@ async function verifyAssertion(assertedCredential) { | |||
signature: encodeURLEncodedBase64(sig), | |||
userHandle: encodeURLEncodedBase64(userHandle), | |||
}, | |||
}), | |||
}, | |||
}); | |||
if (res.status === 500) { | |||
webAuthnError('unknown'); | |||
@@ -88,13 +85,8 @@ async function webauthnRegistered(newCredential) { | |||
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); | |||
const rawId = new Uint8Array(newCredential.rawId); | |||
const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/register`, { | |||
method: 'POST', | |||
headers: { | |||
'X-Csrf-Token': csrfToken, | |||
'Content-Type': 'application/json; charset=utf-8', | |||
}, | |||
body: JSON.stringify({ | |||
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/register`, { | |||
data: { | |||
id: newCredential.id, | |||
rawId: encodeURLEncodedBase64(rawId), | |||
type: newCredential.type, | |||
@@ -102,7 +94,7 @@ async function webauthnRegistered(newCredential) { | |||
attestationObject: encodeURLEncodedBase64(attestationObject), | |||
clientDataJSON: encodeURLEncodedBase64(clientDataJSON), | |||
}, | |||
}), | |||
}, | |||
}); | |||
if (res.status === 409) { | |||
@@ -165,15 +157,11 @@ export function initUserAuthWebAuthnRegister() { | |||
async function webAuthnRegisterRequest() { | |||
const elNickname = document.getElementById('nickname'); | |||
const body = new FormData(); | |||
body.append('name', elNickname.value); | |||
const formData = new FormData(); | |||
formData.append('name', elNickname.value); | |||
const res = await fetch(`${appSubUrl}/user/settings/security/webauthn/request_register`, { | |||
method: 'POST', | |||
headers: { | |||
'X-Csrf-Token': csrfToken, | |||
}, | |||
body, | |||
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/request_register`, { | |||
data: formData, | |||
}); | |||
if (res.status === 409) { |
@@ -2,17 +2,18 @@ import {isObject} from '../utils.js'; | |||
const {csrfToken} = window.config; | |||
// safe HTTP methods that don't need a csrf token | |||
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); | |||
// fetch wrapper, use below method name functions and the `data` option to pass in data | |||
// which will automatically set an appropriate content-type header. For json content, | |||
// only object and array types are currently supported. | |||
function request(url, {headers, data, body, ...other} = {}) { | |||
// which will automatically set an appropriate headers. For json content, only object | |||
// and array types are currently supported. | |||
export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) { | |||
let contentType; | |||
if (!body) { | |||
if (data instanceof FormData) { | |||
contentType = 'multipart/form-data'; | |||
body = data; | |||
} else if (data instanceof URLSearchParams) { | |||
contentType = 'application/x-www-form-urlencoded'; | |||
body = data; | |||
} else if (isObject(data) || Array.isArray(data)) { | |||
contentType = 'application/json'; | |||
@@ -20,12 +21,18 @@ function request(url, {headers, data, body, ...other} = {}) { | |||
} | |||
} | |||
const headersMerged = new Headers({ | |||
...(!safeMethods.has(method.toUpperCase()) && {'x-csrf-token': csrfToken}), | |||
...(contentType && {'content-type': contentType}), | |||
}); | |||
for (const [name, value] of Object.entries(headers)) { | |||
headersMerged.set(name, value); | |||
} | |||
return fetch(url, { | |||
headers: { | |||
'x-csrf-token': csrfToken, | |||
...(contentType && {'content-type': contentType}), | |||
...headers, | |||
}, | |||
method, | |||
headers: headersMerged, | |||
...(body && {body}), | |||
...other, | |||
}); |