mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
Merge pull request #53521 from nextcloud/backport/53326/stable30
[stable30] fix: refactor request token handling and do not update with invalid result
This commit is contained in:
commit
a0713a6d19
27 changed files with 924 additions and 374 deletions
|
|
@ -2,16 +2,17 @@
|
|||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import JSDOMEnvironment from 'jest-environment-jsdom'
|
||||
import FixedJSDOMEnvironment from 'jest-fixed-jsdom'
|
||||
|
||||
// https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
|
||||
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
|
||||
export default class FixJSDOMEnvironment extends FixedJSDOMEnvironment {
|
||||
|
||||
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
|
||||
constructor(...args: ConstructorParameters<typeof FixedJSDOMEnvironment>) {
|
||||
super(...args)
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/3363
|
||||
// 31 ad above switched to vitest and don't have that issue
|
||||
// @ts-expect-error see JSDOMEnvironment
|
||||
this.global.structuredClone = structuredClone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@
|
|||
window.OC = { ...window.OC }
|
||||
window.OCA = { ...window.OCA }
|
||||
window.OCP = { ...window.OCP }
|
||||
|
||||
window._oc_webroot = ''
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ window._oc_appswebroots = {
|
|||
"files": window.webroot + '/apps/files/',
|
||||
"files_sharing": window.webroot + '/apps/files_sharing/'
|
||||
};
|
||||
|
||||
window.OC ??= {};
|
||||
|
||||
OC.config = {
|
||||
session_lifetime: 600 * 1000,
|
||||
session_keepalive: false,
|
||||
|
|
@ -111,6 +114,10 @@ window.Snap.prototype = {
|
|||
|
||||
window.isPhantom = /phantom/i.test(navigator.userAgent);
|
||||
document.documentElement.lang = navigator.language;
|
||||
const el = document.createElement('input');
|
||||
el.id = 'initial-state-core-config';
|
||||
el.value = btoa(JSON.stringify(window.OC.config))
|
||||
document.body.append(el);
|
||||
|
||||
// global setup for all tests
|
||||
(function setupTests() {
|
||||
|
|
|
|||
|
|
@ -119,93 +119,6 @@ describe('Core base tests', function() {
|
|||
})).toEqual('number=123');
|
||||
});
|
||||
});
|
||||
describe('Session heartbeat', function() {
|
||||
var clock,
|
||||
oldConfig,
|
||||
counter;
|
||||
|
||||
beforeEach(function() {
|
||||
clock = sinon.useFakeTimers();
|
||||
oldConfig = OC.config;
|
||||
counter = 0;
|
||||
|
||||
fakeServer.autoRespond = true;
|
||||
fakeServer.autoRespondAfter = 0;
|
||||
fakeServer.respondWith(/\/csrftoken/, function(xhr) {
|
||||
counter++;
|
||||
xhr.respond(200, {'Content-Type': 'application/json'}, '{"token": "pgBEsb3MzTb1ZPd2mfDZbQ6/0j3OrXHMEZrghHcOkg8=:3khw5PSa+wKQVo4f26exFD3nplud9ECjJ8/Y5zk5/k4="}');
|
||||
});
|
||||
$(document).off('ajaxComplete'); // ignore previously registered heartbeats
|
||||
});
|
||||
afterEach(function() {
|
||||
clock.restore();
|
||||
/* jshint camelcase: false */
|
||||
OC.config = oldConfig;
|
||||
$(document).off('ajaxError');
|
||||
$(document).off('ajaxComplete');
|
||||
});
|
||||
it('sends heartbeat half the session lifetime when heartbeat enabled', function() {
|
||||
/* jshint camelcase: false */
|
||||
OC.config = {
|
||||
session_keepalive: true,
|
||||
session_lifetime: 300
|
||||
};
|
||||
window.initCore();
|
||||
|
||||
expect(counter).toEqual(0);
|
||||
|
||||
// less than half, still nothing
|
||||
clock.tick(100 * 1000);
|
||||
expect(counter).toEqual(0);
|
||||
|
||||
// reach past half (160), one call
|
||||
clock.tick(55 * 1000);
|
||||
expect(counter).toEqual(1);
|
||||
|
||||
// almost there to the next, still one
|
||||
clock.tick(140 * 1000);
|
||||
expect(counter).toEqual(1);
|
||||
|
||||
// past it, second call
|
||||
clock.tick(20 * 1000);
|
||||
expect(counter).toEqual(2);
|
||||
});
|
||||
it('does not send heartbeat when heartbeat disabled', function() {
|
||||
/* jshint camelcase: false */
|
||||
OC.config = {
|
||||
session_keepalive: false,
|
||||
session_lifetime: 300
|
||||
};
|
||||
window.initCore();
|
||||
|
||||
expect(counter).toEqual(0);
|
||||
|
||||
clock.tick(1000000);
|
||||
|
||||
// still nothing
|
||||
expect(counter).toEqual(0);
|
||||
});
|
||||
it('limits the heartbeat between one minute and one day', function() {
|
||||
/* jshint camelcase: false */
|
||||
var setIntervalStub = sinon.stub(window, 'setInterval');
|
||||
OC.config = {
|
||||
session_keepalive: true,
|
||||
session_lifetime: 5
|
||||
};
|
||||
window.initCore();
|
||||
expect(setIntervalStub.getCall(0).args[1]).toEqual(60 * 1000);
|
||||
setIntervalStub.reset();
|
||||
|
||||
OC.config = {
|
||||
session_keepalive: true,
|
||||
session_lifetime: 48 * 3600
|
||||
};
|
||||
window.initCore();
|
||||
expect(setIntervalStub.getCall(0).args[1]).toEqual(24 * 3600 * 1000);
|
||||
|
||||
setIntervalStub.restore();
|
||||
});
|
||||
});
|
||||
describe('Parse query string', function() {
|
||||
it('Parses query string from full URL', function() {
|
||||
var query = OC.parseQueryString('http://localhost/stuff.php?q=a&b=x');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
/* eslint-disable */
|
||||
import $ from 'jquery'
|
||||
|
||||
import { getToken } from './requesttoken.js'
|
||||
import { getToken } from './requesttoken.ts'
|
||||
|
||||
/**
|
||||
* Create a new event source
|
||||
|
|
|
|||
|
|
@ -49,9 +49,7 @@ import {
|
|||
getPort,
|
||||
getProtocol,
|
||||
} from './host.js'
|
||||
import {
|
||||
getToken as getRequestToken,
|
||||
} from './requesttoken.js'
|
||||
import { getRequestToken } from './requesttoken.ts'
|
||||
import {
|
||||
hideMenus,
|
||||
registerMenu,
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Document} global the document to read the initial value from
|
||||
* @param {Function} emit the function to invoke for every new token
|
||||
* @return {object}
|
||||
*/
|
||||
export const manageToken = (global, emit) => {
|
||||
let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken')
|
||||
|
||||
return {
|
||||
getToken: () => token,
|
||||
setToken: newToken => {
|
||||
token = newToken
|
||||
|
||||
emit('csrf-token-update', {
|
||||
token,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const manageFromDocument = manageToken(document, emit)
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
export const getToken = manageFromDocument.getToken
|
||||
|
||||
/**
|
||||
* @param {string} newToken new token
|
||||
*/
|
||||
export const setToken = manageFromDocument.setToken
|
||||
49
core/src/OC/requesttoken.ts
Normal file
49
core/src/OC/requesttoken.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
/**
|
||||
* Get the current CSRF token.
|
||||
*/
|
||||
export function getRequestToken(): string {
|
||||
return document.head.dataset.requesttoken!
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new CSRF token (e.g. because of session refresh).
|
||||
* This also emits an event bus event for the updated token.
|
||||
*
|
||||
* @param token - The new token
|
||||
* @fires Error - If the passed token is not a potential valid token
|
||||
*/
|
||||
export function setRequestToken(token: string): void {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('Invalid CSRF token given', { cause: { token } })
|
||||
}
|
||||
|
||||
document.head.dataset.requesttoken = token
|
||||
emit('csrf-token-update', { token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the request token from the API.
|
||||
* This does also set it on the current context, see `setRequestToken`.
|
||||
*
|
||||
* @fires Error - If the request failed
|
||||
*/
|
||||
export async function fetchRequestToken(): Promise<string> {
|
||||
const url = generateUrl('/csrftoken')
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not fetch CSRF token from API', { cause: response })
|
||||
}
|
||||
|
||||
const { token } = await response.json()
|
||||
setRequestToken(token)
|
||||
return token
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ import 'strengthify/strengthify.css'
|
|||
import OC from './OC/index.js'
|
||||
import OCP from './OCP/index.js'
|
||||
import OCA from './OCA/index.js'
|
||||
import { getToken as getRequestToken } from './OC/requesttoken.js'
|
||||
import { getRequestToken } from './OC/requesttoken.ts'
|
||||
|
||||
const warnIfNotTesting = function() {
|
||||
if (window.TESTING === undefined) {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import _ from 'underscore'
|
|||
import $ from 'jquery'
|
||||
import moment from 'moment'
|
||||
|
||||
import { initSessionHeartBeat } from './session-heartbeat.js'
|
||||
import OC from './OC/index.js'
|
||||
import { initSessionHeartBeat } from './session-heartbeat.ts'
|
||||
import { setUp as setUpContactsMenu } from './components/ContactsMenu.js'
|
||||
import { setUp as setUpMainMenu } from './components/MainMenu.js'
|
||||
import { setUp as setUpUserMenu } from './components/UserMenu.js'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import $ from 'jquery'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { getToken } from './OC/requesttoken.js'
|
||||
import { getRequestToken } from './OC/requesttoken.ts'
|
||||
import getURLParameter from './Util/get-url-parameter.js'
|
||||
|
||||
import './jquery/showpassword.js'
|
||||
|
|
@ -138,7 +138,7 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||
t('core', 'Strong password'),
|
||||
],
|
||||
drawTitles: true,
|
||||
nonce: btoa(getToken()),
|
||||
nonce: btoa(getRequestToken()),
|
||||
})
|
||||
|
||||
$('#dbpass').showPassword().keyup()
|
||||
|
|
|
|||
4
core/src/jquery/requesttoken.js
vendored
4
core/src/jquery/requesttoken.js
vendored
|
|
@ -5,11 +5,11 @@
|
|||
|
||||
import $ from 'jquery'
|
||||
|
||||
import { getToken } from '../OC/requesttoken.js'
|
||||
import { getRequestToken } from '../OC/requesttoken.ts'
|
||||
|
||||
$(document).on('ajaxSend', function(elm, xhr, settings) {
|
||||
if (settings.crossDomain === false) {
|
||||
xhr.setRequestHeader('requesttoken', getToken())
|
||||
xhr.setRequestHeader('requesttoken', getRequestToken())
|
||||
xhr.setRequestHeader('OCS-APIREQUEST', 'true')
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import $ from 'jquery'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
import OC from './OC/index.js'
|
||||
import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken.js'
|
||||
|
||||
let config = null
|
||||
/**
|
||||
* The legacy jsunit tests overwrite OC.config before calling initCore
|
||||
* therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat
|
||||
*/
|
||||
const loadConfig = () => {
|
||||
try {
|
||||
config = loadState('core', 'config')
|
||||
} catch (e) {
|
||||
// This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls
|
||||
config = OC.config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* session heartbeat (defaults to enabled)
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
const keepSessionAlive = () => {
|
||||
return config.session_keepalive === undefined
|
||||
|| !!config.session_keepalive
|
||||
}
|
||||
|
||||
/**
|
||||
* get interval in seconds
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
const getInterval = () => {
|
||||
let interval = NaN
|
||||
if (config.session_lifetime) {
|
||||
interval = Math.floor(config.session_lifetime / 2)
|
||||
}
|
||||
|
||||
// minimum one minute, max 24 hours, default 15 minutes
|
||||
return Math.min(
|
||||
24 * 3600,
|
||||
Math.max(
|
||||
60,
|
||||
isNaN(interval) ? 900 : interval,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const getToken = async () => {
|
||||
const url = generateUrl('/csrftoken')
|
||||
|
||||
// Not using Axios here as Axios is not stubbable with the sinon fake server
|
||||
// see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises
|
||||
// see js/tests/specs/coreSpec.js for the tests
|
||||
const resp = await $.get(url)
|
||||
|
||||
return resp.token
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
setRequestToken(token)
|
||||
} catch (e) {
|
||||
console.error('session heartbeat failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
const interval = setInterval(poll, getInterval() * 1000)
|
||||
|
||||
console.info('session heartbeat polling started')
|
||||
|
||||
return interval
|
||||
}
|
||||
|
||||
const registerAutoLogout = () => {
|
||||
if (!config.auto_logout || !getCurrentUser()) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastActive = Date.now()
|
||||
window.addEventListener('mousemove', e => {
|
||||
lastActive = Date.now()
|
||||
localStorage.setItem('lastActive', lastActive)
|
||||
})
|
||||
|
||||
window.addEventListener('touchstart', e => {
|
||||
lastActive = Date.now()
|
||||
localStorage.setItem('lastActive', lastActive)
|
||||
})
|
||||
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key !== 'lastActive') {
|
||||
return
|
||||
}
|
||||
lastActive = e.newValue
|
||||
})
|
||||
|
||||
let intervalId = 0
|
||||
const logoutCheck = () => {
|
||||
const timeout = Date.now() - config.session_lifetime * 1000
|
||||
if (lastActive < timeout) {
|
||||
clearTimeout(intervalId)
|
||||
console.info('Inactivity timout reached, logging out')
|
||||
const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
|
||||
window.location = logoutUrl
|
||||
}
|
||||
}
|
||||
intervalId = setInterval(logoutCheck, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the server periodically to ensure that session and CSRF
|
||||
* token doesn't expire
|
||||
*/
|
||||
export const initSessionHeartBeat = () => {
|
||||
loadConfig()
|
||||
|
||||
registerAutoLogout()
|
||||
|
||||
if (!keepSessionAlive()) {
|
||||
console.info('session heartbeat disabled')
|
||||
return
|
||||
}
|
||||
let interval = startPolling()
|
||||
|
||||
window.addEventListener('online', async () => {
|
||||
console.info('browser is online again, resuming heartbeat')
|
||||
interval = startPolling()
|
||||
try {
|
||||
await poll()
|
||||
console.info('session token successfully updated after resuming network')
|
||||
|
||||
// Let apps know we're online and requests will have the new token
|
||||
emit('networkOnline', {
|
||||
success: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('could not update session token after resuming network', e)
|
||||
|
||||
// Let apps know we're online but requests might have an outdated token
|
||||
emit('networkOnline', {
|
||||
success: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
console.info('browser is offline, stopping heartbeat')
|
||||
|
||||
// Let apps know we're offline
|
||||
emit('networkOffline', {})
|
||||
|
||||
clearInterval(interval)
|
||||
console.info('session heartbeat polling stopped')
|
||||
})
|
||||
}
|
||||
158
core/src/session-heartbeat.ts
Normal file
158
core/src/session-heartbeat.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import {
|
||||
fetchRequestToken,
|
||||
getRequestToken,
|
||||
} from './OC/requesttoken.ts'
|
||||
import logger from './logger.js'
|
||||
|
||||
interface OcJsConfig {
|
||||
auto_logout: boolean
|
||||
session_keepalive: boolean
|
||||
session_lifetime: number
|
||||
}
|
||||
|
||||
// This is always set, exception would be e.g. error pages where this is undefined
|
||||
const {
|
||||
auto_logout: autoLogout,
|
||||
session_keepalive: keepSessionAlive,
|
||||
session_lifetime: sessionLifetime,
|
||||
} = loadState<Partial<OcJsConfig>>('core', 'config', {})
|
||||
|
||||
/**
|
||||
* Calls the server periodically to ensure that session and CSRF
|
||||
* token doesn't expire
|
||||
*/
|
||||
export function initSessionHeartBeat() {
|
||||
registerAutoLogout()
|
||||
|
||||
if (!keepSessionAlive) {
|
||||
logger.info('Session heartbeat disabled')
|
||||
return
|
||||
}
|
||||
|
||||
let interval = startPolling()
|
||||
window.addEventListener('online', async () => {
|
||||
logger.info('Browser is online again, resuming heartbeat')
|
||||
|
||||
interval = startPolling()
|
||||
try {
|
||||
await poll()
|
||||
logger.info('Session token successfully updated after resuming network')
|
||||
|
||||
// Let apps know we're online and requests will have the new token
|
||||
emit('networkOnline', {
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('could not update session token after resuming network', { error })
|
||||
|
||||
// Let apps know we're online but requests might have an outdated token
|
||||
emit('networkOnline', {
|
||||
success: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
logger.info('Browser is offline, stopping heartbeat')
|
||||
|
||||
// Let apps know we're offline
|
||||
emit('networkOffline', {})
|
||||
|
||||
clearInterval(interval)
|
||||
logger.info('Session heartbeat polling stopped')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interval in seconds
|
||||
*/
|
||||
function getInterval(): number {
|
||||
const interval = sessionLifetime
|
||||
? Math.floor(sessionLifetime / 2)
|
||||
: 900
|
||||
|
||||
// minimum one minute, max 24 hours, default 15 minutes
|
||||
return Math.min(
|
||||
24 * 3600,
|
||||
Math.max(
|
||||
60,
|
||||
interval,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the CSRF token for changes.
|
||||
* This will also extend the current session if needed.
|
||||
*/
|
||||
async function poll() {
|
||||
try {
|
||||
await fetchRequestToken()
|
||||
} catch (error) {
|
||||
logger.error('session heartbeat failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an window interval with the polling as the callback.
|
||||
*
|
||||
* @return The interval id
|
||||
*/
|
||||
function startPolling(): number {
|
||||
const interval = window.setInterval(poll, getInterval() * 1000)
|
||||
|
||||
logger.info('session heartbeat polling started')
|
||||
return interval
|
||||
}
|
||||
|
||||
/**
|
||||
* If enabled this will register event listeners to track if a user is active.
|
||||
* If not the user will be automatically logged out after the configured IDLE time.
|
||||
*/
|
||||
function registerAutoLogout() {
|
||||
if (!autoLogout || !getCurrentUser()) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastActive = Date.now()
|
||||
window.addEventListener('mousemove', () => {
|
||||
lastActive = Date.now()
|
||||
localStorage.setItem('lastActive', JSON.stringify(lastActive))
|
||||
})
|
||||
|
||||
window.addEventListener('touchstart', () => {
|
||||
lastActive = Date.now()
|
||||
localStorage.setItem('lastActive', JSON.stringify(lastActive))
|
||||
})
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== 'lastActive') {
|
||||
return
|
||||
}
|
||||
if (event.newValue === null) {
|
||||
return
|
||||
}
|
||||
lastActive = JSON.parse(event.newValue)
|
||||
})
|
||||
|
||||
let intervalId = 0
|
||||
const logoutCheck = () => {
|
||||
const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000
|
||||
if (lastActive < timeout) {
|
||||
clearTimeout(intervalId)
|
||||
logger.info('Inactivity timout reached, logging out')
|
||||
const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
|
||||
window.location.href = logoutUrl
|
||||
}
|
||||
}
|
||||
intervalId = window.setInterval(logoutCheck, 1000)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
|
||||
import { manageToken, setToken } from '../../OC/requesttoken.js'
|
||||
|
||||
describe('request token', () => {
|
||||
|
||||
let emit
|
||||
let manager
|
||||
const token = 'abc123'
|
||||
|
||||
beforeEach(() => {
|
||||
emit = jest.fn()
|
||||
const head = window.document.getElementsByTagName('head')[0]
|
||||
head.setAttribute('data-requesttoken', token)
|
||||
|
||||
manager = manageToken(window.document, emit)
|
||||
})
|
||||
|
||||
test('reads the token from the document', () => {
|
||||
expect(manager.getToken()).toBe('abc123')
|
||||
})
|
||||
|
||||
test('remembers the updated token', () => {
|
||||
manager.setToken('bca321')
|
||||
|
||||
expect(manager.getToken()).toBe('bca321')
|
||||
})
|
||||
|
||||
describe('@nextcloud/auth integration', () => {
|
||||
let listener
|
||||
|
||||
beforeEach(() => {
|
||||
listener = jest.fn()
|
||||
|
||||
subscribe('csrf-token-update', listener)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe('csrf-token-update', listener)
|
||||
})
|
||||
|
||||
test('fires off an event for @nextcloud/auth', () => {
|
||||
setToken('123')
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({ token: '123' })
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
147
core/src/tests/OC/requesttoken.spec.ts
Normal file
147
core/src/tests/OC/requesttoken.spec.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { setupServer } from 'msw/node'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts'
|
||||
import * as eventbus from '@nextcloud/event-bus'
|
||||
|
||||
jest.mock('@nextcloud/event-bus', () => ({ emit: jest.fn() }))
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
describe('getRequestToken', () => {
|
||||
it('can read the token from DOM', () => {
|
||||
mockToken('tokenmock-123')
|
||||
expect(getRequestToken()).toBe('tokenmock-123')
|
||||
})
|
||||
|
||||
it('can handle missing token', () => {
|
||||
mockToken(undefined)
|
||||
expect(getRequestToken()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setRequestToken', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('does emit an event on change', () => {
|
||||
setRequestToken('new-token')
|
||||
expect(eventbus.emit).toBeCalledTimes(1)
|
||||
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
|
||||
})
|
||||
|
||||
it('does set the new token to the DOM', () => {
|
||||
setRequestToken('new-token')
|
||||
expect(document.head.dataset.requesttoken).toBe('new-token')
|
||||
})
|
||||
|
||||
it('does remember the new token', () => {
|
||||
mockToken('old-token')
|
||||
setRequestToken('new-token')
|
||||
expect(getRequestToken()).toBe('new-token')
|
||||
})
|
||||
|
||||
it('throws if the token is not a string', () => {
|
||||
// @ts-expect-error mocking
|
||||
expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given')
|
||||
})
|
||||
|
||||
it('throws if the token is not valid', () => {
|
||||
expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
|
||||
})
|
||||
|
||||
it('does not emit an event if the token is not valid', () => {
|
||||
expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
|
||||
expect(eventbus.emit).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRequestToken', () => {
|
||||
const successfullCsrf = http.get('/index.php/csrftoken', () => {
|
||||
return HttpResponse.json({ token: 'new-token' })
|
||||
})
|
||||
const forbiddenCsrf = http.get('/index.php/csrftoken', () => {
|
||||
return HttpResponse.json([], { status: 403 })
|
||||
})
|
||||
const serverErrorCsrf = http.get('/index.php/csrftoken', () => {
|
||||
return HttpResponse.json([], { status: 500 })
|
||||
})
|
||||
const networkErrorCsrf = http.get('/index.php/csrftoken', () => {
|
||||
return new HttpResponse(null, { type: 'error' })
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('correctly parses response', async () => {
|
||||
server.use(successfullCsrf)
|
||||
|
||||
mockToken('oldToken')
|
||||
const token = await fetchRequestToken()
|
||||
expect(token).toBe('new-token')
|
||||
})
|
||||
|
||||
it('sets the token', async () => {
|
||||
server.use(successfullCsrf)
|
||||
|
||||
mockToken('oldToken')
|
||||
await fetchRequestToken()
|
||||
expect(getRequestToken()).toBe('new-token')
|
||||
})
|
||||
|
||||
it('does emit an event', async () => {
|
||||
server.use(successfullCsrf)
|
||||
|
||||
await fetchRequestToken()
|
||||
expect(eventbus.emit).toBeCalledTimes(1)
|
||||
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
|
||||
})
|
||||
|
||||
it('handles 403 error due to invalid cookies', async () => {
|
||||
server.use(forbiddenCsrf)
|
||||
|
||||
mockToken('oldToken')
|
||||
await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
|
||||
expect(getRequestToken()).toBe('oldToken')
|
||||
})
|
||||
|
||||
it('handles server error', async () => {
|
||||
server.use(serverErrorCsrf)
|
||||
|
||||
mockToken('oldToken')
|
||||
await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
|
||||
expect(getRequestToken()).toBe('oldToken')
|
||||
})
|
||||
|
||||
it('handles network error', async () => {
|
||||
server.use(networkErrorCsrf)
|
||||
|
||||
mockToken('oldToken')
|
||||
await expect(() => fetchRequestToken()).rejects.toThrow()
|
||||
expect(getRequestToken()).toBe('oldToken')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Mock the request token directly so we can test reading it.
|
||||
*
|
||||
* @param token - The CSRF token to mock
|
||||
*/
|
||||
function mockToken(token?: string) {
|
||||
if (token === undefined) {
|
||||
delete document.head.dataset.requesttoken
|
||||
} else {
|
||||
document.head.dataset.requesttoken = token
|
||||
}
|
||||
}
|
||||
125
core/src/tests/OC/session-heartbeat.spec.ts
Normal file
125
core/src/tests/OC/session-heartbeat.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
var requestToken = {
|
||||
fetchRequestToken: jest.fn<Promise<string>, []>(),
|
||||
setRequestToken: jest.fn<void, [string]>(),
|
||||
}
|
||||
jest.mock('../../OC/requesttoken.ts', () => requestToken)
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
var initialState = { loadState: jest.fn() }
|
||||
jest.mock('@nextcloud/initial-state', () => initialState)
|
||||
|
||||
describe('Session heartbeat', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.resetModules()
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
|
||||
initialState.loadState.mockImplementationOnce(() => ({
|
||||
session_keepalive: true,
|
||||
session_lifetime: 300,
|
||||
}))
|
||||
|
||||
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
|
||||
initSessionHeartBeat()
|
||||
|
||||
// initial state loaded
|
||||
expect(initialState.loadState).toBeCalledWith('core', 'config', {})
|
||||
|
||||
// less than half, still nothing
|
||||
await jest.advanceTimersByTimeAsync(100 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
|
||||
// reach past half, one call
|
||||
await jest.advanceTimersByTimeAsync(60 * 1000)
|
||||
expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
|
||||
|
||||
// almost there to the next, still one
|
||||
await jest.advanceTimersByTimeAsync(135 * 1000)
|
||||
expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
|
||||
|
||||
// past it, second call
|
||||
await jest.advanceTimersByTimeAsync(5 * 1000)
|
||||
expect(requestToken.fetchRequestToken).toBeCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not send heartbeat when heartbeat disabled', async () => {
|
||||
initialState.loadState.mockImplementationOnce(() => ({
|
||||
session_keepalive: false,
|
||||
session_lifetime: 300,
|
||||
}))
|
||||
|
||||
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
|
||||
initSessionHeartBeat()
|
||||
|
||||
// initial state loaded
|
||||
expect(initialState.loadState).toBeCalledWith('core', 'config', {})
|
||||
|
||||
// less than half, still nothing
|
||||
await jest.advanceTimersByTimeAsync(100 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
|
||||
// more than one, still nothing
|
||||
await jest.advanceTimersByTimeAsync(300 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('limit heartbeat to at least one minute', async () => {
|
||||
initialState.loadState.mockImplementationOnce(() => ({
|
||||
session_keepalive: true,
|
||||
session_lifetime: 55,
|
||||
}))
|
||||
|
||||
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
|
||||
initSessionHeartBeat()
|
||||
|
||||
// initial state loaded
|
||||
expect(initialState.loadState).toBeCalledWith('core', 'config', {})
|
||||
|
||||
// 30 / 55 seconds
|
||||
await jest.advanceTimersByTimeAsync(30 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
|
||||
// 59 / 55 seconds should not be called except it does not limit
|
||||
await jest.advanceTimersByTimeAsync(29 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
|
||||
// now one minute has passed
|
||||
await jest.advanceTimersByTimeAsync(1000)
|
||||
expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it('limit heartbeat to at least one minute', async () => {
|
||||
initialState.loadState.mockImplementationOnce(() => ({
|
||||
session_keepalive: true,
|
||||
session_lifetime: 50 * 60 * 60,
|
||||
}))
|
||||
|
||||
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
|
||||
initSessionHeartBeat()
|
||||
|
||||
// initial state loaded
|
||||
expect(initialState.loadState).toBeCalledWith('core', 'config', {})
|
||||
|
||||
// 23 hours
|
||||
await jest.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
|
||||
expect(requestToken.fetchRequestToken).not.toBeCalled()
|
||||
|
||||
// one day - it should be called now
|
||||
await jest.advanceTimersByTimeAsync(60 * 60 * 1000)
|
||||
expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
4
dist/core-install.js
vendored
4
dist/core-install.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-install.js.map
vendored
2
dist/core-install.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-login.js
vendored
4
dist/core-login.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-login.js.map
vendored
2
dist/core-login.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-main.js
vendored
4
dist/core-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-main.js.map
vendored
2
dist/core-main.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -5,6 +5,7 @@
|
|||
import type { Config } from 'jest'
|
||||
|
||||
// TODO: find a way to consolidate this in one place, with webpack.common.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ignorePatterns = [
|
||||
'@buttercup/fetch',
|
||||
'@juliushaertl',
|
||||
|
|
@ -19,6 +20,7 @@ const ignorePatterns = [
|
|||
'is-svg',
|
||||
'layerr',
|
||||
'mime',
|
||||
'node-fetch',
|
||||
'p-cancelable',
|
||||
'p-limit',
|
||||
'p-queue',
|
||||
|
|
@ -44,6 +46,11 @@ const config: Config = {
|
|||
],
|
||||
|
||||
testEnvironment: './__tests__/FixJSDOMEnvironment.ts',
|
||||
testEnvironmentOptions: {
|
||||
// https://mswjs.io/docs/migrations/1.x-to-2.x#cannot-find-module-mswnode-jsdom
|
||||
customExportConditions: [''],
|
||||
},
|
||||
|
||||
preset: 'ts-jest/presets/js-with-ts',
|
||||
|
||||
roots: [
|
||||
|
|
@ -63,7 +70,7 @@ const config: Config = {
|
|||
}],
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(' + ignorePatterns.join('|') + ')/)',
|
||||
// 'node_modules/(?!(' + ignorePatterns.join('|') + ')/)',
|
||||
],
|
||||
|
||||
// Allow mocking svg files
|
||||
|
|
|
|||
403
package-lock.json
generated
403
package-lock.json
generated
|
|
@ -27,7 +27,7 @@
|
|||
"@nextcloud/moment": "^1.3.4",
|
||||
"@nextcloud/password-confirmation": "^5.3.1",
|
||||
"@nextcloud/paths": "^2.2.1",
|
||||
"@nextcloud/router": "^3.0.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/sharing": "^0.2.4",
|
||||
"@nextcloud/upload": "^1.10.0",
|
||||
"@nextcloud/vue": "^8.27.0",
|
||||
|
|
@ -136,6 +136,7 @@
|
|||
"jest": "^29.0.3",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-fixed-jsdom": "^0.0.9",
|
||||
"jest-location-mock": "^1.0.9",
|
||||
"jsdoc": "^4.0.4",
|
||||
"karma": "^6.4.4",
|
||||
|
|
@ -146,6 +147,7 @@
|
|||
"karma-spec-reporter": "^0.0.36",
|
||||
"karma-viewport": "^1.0.9",
|
||||
"mime": "^4.0.7",
|
||||
"msw": "^2.10.2",
|
||||
"puppeteer": "^24.10.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"regextras": "^0.8.0",
|
||||
|
|
@ -2122,6 +2124,57 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cookie": "^0.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/statuses": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz",
|
||||
"integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"statuses": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/statuses/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@bundled-es-modules/tough-cookie": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz",
|
||||
"integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/tough-cookie": "^4.0.5",
|
||||
"tough-cookie": "^4.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@buttercup/fetch": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz",
|
||||
|
|
@ -3180,6 +3233,148 @@
|
|||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@inquirer/confirm": {
|
||||
"version": "5.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.12.tgz",
|
||||
"integrity": "sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.1.13",
|
||||
"@inquirer/type": "^3.0.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core": {
|
||||
"version": "10.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.13.tgz",
|
||||
"integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/figures": "^1.0.12",
|
||||
"@inquirer/type": "^3.0.7",
|
||||
"ansi-escapes": "^4.3.2",
|
||||
"cli-width": "^4.1.0",
|
||||
"mute-stream": "^2.0.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"wrap-ansi": "^6.2.0",
|
||||
"yoctocolors-cjs": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/figures": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz",
|
||||
"integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/type": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz",
|
||||
"integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -4567,6 +4762,24 @@
|
|||
"integrity": "sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.39.2",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.2.tgz",
|
||||
"integrity": "sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/logger": "^0.3.0",
|
||||
"@open-draft/until": "^2.0.0",
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.3",
|
||||
"strict-event-emitter": "^0.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/auth": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.5.1.tgz",
|
||||
|
|
@ -5358,6 +5571,31 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@open-draft/deferred-promise": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@open-draft/logger": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
|
||||
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@open-draft/until": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||
|
|
@ -7205,6 +7443,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/statuses": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
|
|
@ -10378,6 +10623,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
"integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/clipboard": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
|
||||
|
|
@ -16031,6 +16286,16 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
"version": "16.11.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
|
||||
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/handle-thing": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
||||
|
|
@ -16305,6 +16570,13 @@
|
|||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/headers-polyfill": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
|
||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
|
|
@ -17281,6 +17553,13 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-node-process": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
|
||||
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
|
|
@ -18856,6 +19135,19 @@
|
|||
"promise-polyfill": "^8.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-fixed-jsdom": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz",
|
||||
"integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jest-environment-jsdom": ">=28.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
|
|
@ -22694,6 +22986,78 @@
|
|||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.10.2",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.10.2.tgz",
|
||||
"integrity": "sha512-RCKM6IZseZQCWcSWlutdf590M8nVfRHG1ImwzOtwz8IYxgT4zhUO0rfTcTvDGiaFE0Rhcc+h43lcF3Jc9gFtwQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bundled-es-modules/cookie": "^2.0.1",
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
"@bundled-es-modules/tough-cookie": "^0.1.6",
|
||||
"@inquirer/confirm": "^5.0.0",
|
||||
"@mswjs/interceptors": "^0.39.1",
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/until": "^2.1.0",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/statuses": "^2.0.4",
|
||||
"graphql": "^16.8.1",
|
||||
"headers-polyfill": "^4.0.2",
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.3",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"type-fest": "^4.26.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"msw": "cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mswjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">= 4.8.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/msw/node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msw/node_modules/path-to-regexp": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msw/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
|
|
@ -22709,6 +23073,16 @@
|
|||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.22.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
|
||||
|
|
@ -23438,6 +23812,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/outvariant": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
|
||||
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
|
|
@ -27582,6 +27963,13 @@
|
|||
"resolved": "git+ssh://git@github.com/nextcloud/strengthify.git#d78452649da2cd59df594a2a5c210cb7045ac899",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
|
@ -32067,6 +32455,19 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors-cjs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
|
||||
"integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.64",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.64.tgz",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
"@nextcloud/moment": "^1.3.4",
|
||||
"@nextcloud/password-confirmation": "^5.3.1",
|
||||
"@nextcloud/paths": "^2.2.1",
|
||||
"@nextcloud/router": "^3.0.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/sharing": "^0.2.4",
|
||||
"@nextcloud/upload": "^1.10.0",
|
||||
"@nextcloud/vue": "^8.27.0",
|
||||
|
|
@ -164,6 +164,7 @@
|
|||
"jest": "^29.0.3",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-fixed-jsdom": "^0.0.9",
|
||||
"jest-location-mock": "^1.0.9",
|
||||
"jsdoc": "^4.0.4",
|
||||
"karma": "^6.4.4",
|
||||
|
|
@ -174,6 +175,7 @@
|
|||
"karma-spec-reporter": "^0.0.36",
|
||||
"karma-viewport": "^1.0.9",
|
||||
"mime": "^4.0.7",
|
||||
"msw": "^2.10.2",
|
||||
"puppeteer": "^24.10.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"regextras": "^0.8.0",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["./apps/**/*.ts", "./apps/**/*.vue", "./core/**/*.ts", "./core/**/*.vue", "./*.d.ts"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"types": ["jest", "node", "vue", "vue-router"],
|
||||
"outDir": "./dist/",
|
||||
"target": "ESNext",
|
||||
"module": "esnext",
|
||||
// Set module resolution to bundler and `noEmit` to be able to set `allowImportingTsExtensions`, so we can import Typescript with .ts extension
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
// Allow ts to import js files
|
||||
|
|
|
|||
Loading…
Reference in a new issue