Feat/startup config api (#518)

* feat: add api for config

* feat: add data service to client

* feat: update client pages with values from config endpoint

* test: update tests

* Update configurations and documentation to remove VITE_SHOW_GOOGLE_LOGIN_OPTION and change VITE_APP_TITLE to APP_TITLE

* include APP_TITLE with startup config

* Add test for new route

* update backend-review pipeline

* comment out test until we can figure out testing routes in CI

* update: .env.example

---------

Co-authored-by: fuegovic <32828263+fuegovic@users.noreply.github.com>
This commit is contained in:
Dan Orlando 2023-06-15 09:36:34 -07:00 committed by GitHub
parent 2da81db440
commit 3634d8691a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 419 additions and 75 deletions

View file

@ -2,6 +2,8 @@
# Server configuration:
##########################
APP_TITLE=LibreChat
# The server will listen to localhost:3080 by default. You can change the target IP as you want.
# If you want to make this server available externally, for example to share the server with others
# or expose this from a Docker container, set host to 0.0.0.0 or your external IP interface.
@ -171,6 +173,9 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
# User System:
##########################
# Allow Public Registration
ALLOW_REGISTRATION=true
# JWT Secrets
JWT_SECRET=secret
JWT_REFRESH_SECRET=secret
@ -197,19 +202,3 @@ SESSION_EXPIRY=(1000 * 60 * 60 * 24) * 7
DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080
###########################
# Frontend Configuration (Vite):
###########################
# Custom app name, this text will be displayed in the landing page and the footer.
VITE_APP_TITLE="LibreChat"
# Enable Social Login
# This enables/disables the Login with Google button on the login page.
# Set to true if you have registered the app with google cloud services
# and have set the GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET above
VITE_SHOW_GOOGLE_LOGIN_OPTION=false
# Allow Public Registration
ALLOW_REGISTRATION=true

View file

@ -1,4 +1,3 @@
name: Backend Unit Tests
on:
push:
@ -34,7 +33,7 @@ jobs:
run: npm ci
# - name: Install Linux X64 Sharp
# run: npm install --platform=linux --arch=x64 --verbose sharp
# run: npm install --platform=linux --arch=x64 --verbose sharp
- name: Run unit tests
run: cd api && npm run test:ci
run: cd api && npm run test:ci

View file

@ -64,6 +64,7 @@
"devDependencies": {
"jest": "^29.5.0",
"nodemon": "^2.0.20",
"path": "^0.12.7"
"path": "^0.12.7",
"supertest": "^6.3.3"
}
}

View file

@ -54,6 +54,7 @@ config.validate(); // Validate the config
app.use('/api/tokenizer', routes.tokenizer);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/plugins', routes.plugins);
app.use('/api/config', routes.config);
// static files
app.get('/*', function (req, res) {

View file

@ -0,0 +1,37 @@
const request = require('supertest');
const express = require('express');
const routes = require('../');
const app = express();
app.use('/api/config', routes.config);
afterEach(() => {
delete process.env.APP_TITLE;
delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET;
delete process.env.DOMAIN_SERVER;
delete process.env.ALLOW_REGISTRATION;
});
//TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why.
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('GET /', () => {
it('should return 200 and the correct body', async () => {
process.env.APP_TITLE = 'Test Title';
process.env.GOOGLE_CLIENT_ID = 'Test Google Client Id';
process.env.GOOGLE_CLIENT_SECRET = 'Test Google Client Secret';
process.env.DOMAIN_SERVER = 'http://test-server.com';
process.env.ALLOW_REGISTRATION = 'true';
const response = await request(app).get('/');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
appTitle: 'Test Title',
googleLoginEnabled: true,
serverDomain: 'http://test-server.com',
registrationEnabled: 'true',
});
});
});

View file

@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
router.get('/', async function (req, res) {
try {
const appTitle = process.env.APP_TITLE || 'LibreChat';
const googleLoginEnabled = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080';
const registrationEnabled = process.env.ALLOW_REGISTRATION || true;
return res.status(200).send({appTitle, googleLoginEnabled, serverDomain, registrationEnabled});
} catch (err) {
console.error(err);
return res.status(500).send({error: err.message});
}
});
module.exports = router;

View file

@ -10,6 +10,7 @@ const oauth = require('./oauth');
const { router: endpoints } = require('./endpoints');
const plugins = require('./plugins');
const user = require('./user');
const config = require('./config');
module.exports = {
search,
@ -23,5 +24,6 @@ module.exports = {
user,
tokenizer,
endpoints,
plugins
plugins,
config
};

View file

@ -2,10 +2,11 @@ import { useEffect } from 'react';
import LoginForm from './LoginForm';
import { useAuthContext } from '~/hooks/AuthContext';
import { useNavigate } from 'react-router-dom';
import { SHOW_GOOGLE_LOGIN_OPTION, ALLOW_REGISTRATION, DOMAIN_SERVER } from "~/utils/envConstants";
import { useGetStartupConfig } from '~/data-provider';
function Login() {
const { login, error, isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const navigate = useNavigate();
@ -29,7 +30,7 @@ function Login() {
</div>
)}
<LoginForm onSubmit={login} />
{ALLOW_REGISTRATION && (
{startupConfig?.registrationEnabled && (
<p className="my-4 text-center text-sm font-light text-gray-700">
{' '}
Don&apos;t have an account?{' '}
@ -38,7 +39,7 @@ function Login() {
</a>
</p>
)}
{SHOW_GOOGLE_LOGIN_OPTION && (
{startupConfig?.googleLoginEnabled && (
<>
<div className="relative mt-6 flex w-full items-center justify-center border border-t uppercase">
<div className="absolute bg-white px-3 text-xs">Or</div>
@ -47,7 +48,7 @@ function Login() {
<a
aria-label="Login with Google"
className="justify-left flex w-full items-center space-x-3 rounded-md border border-gray-300 px-5 py-3 hover:bg-gray-50 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1"
href={`${DOMAIN_SERVER}/oauth/google`}
href={`${startupConfig.serverDomain}/oauth/google`}
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -1,11 +1,11 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useRegisterUserMutation, TRegisterUser } from '~/data-provider';
import { SHOW_GOOGLE_LOGIN_OPTION, DOMAIN_SERVER } from '~/utils/envConstants';
import { useRegisterUserMutation, TRegisterUser, useGetStartupConfig } from '~/data-provider';
function Registration() {
const navigate = useNavigate();
const { data: startupConfig } = useGetStartupConfig();
const {
register,
@ -34,6 +34,12 @@ function Registration() {
});
};
useEffect(() => {
if (startupConfig?.registrationEnabled === false) {
navigate('/login');
}
}, [startupConfig, navigate]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white pt-6 sm:pt-0">
<div className="mt-6 w-96 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg">
@ -266,7 +272,7 @@ function Registration() {
Login
</a>
</p>
{SHOW_GOOGLE_LOGIN_OPTION && (
{startupConfig?.googleLoginEnabled && (
<>
<div className="relative mt-6 flex w-full items-center justify-center border border-t uppercase">
<div className="absolute bg-white px-3 text-xs">Or</div>
@ -275,7 +281,7 @@ function Registration() {
<div className="mt-4 flex gap-x-2">
<a
aria-label="Login with Google"
href={`${DOMAIN_SERVER}/oauth/google`}
href={`${startupConfig.serverDomain}/oauth/google`}
className="justify-left flex w-full items-center space-x-3 rounded-md border border-gray-300 px-5 py-3 hover:bg-gray-50 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1"
>
<svg

View file

@ -3,12 +3,6 @@ import userEvent from '@testing-library/user-event';
import Login from '../Login';
import * as mockDataProvider from '~/data-provider';
jest.mock('~/utils/envConstants', () => ({
DOMAIN_SERVER: 'mock-server',
SHOW_GOOGLE_LOGIN_OPTION: true,
ALLOW_REGISTRATION: true
}));
jest.mock('~/data-provider');
const setup = ({
@ -23,6 +17,15 @@ const setup = ({
mutate: jest.fn(),
data: {},
isSuccess: false
},
useGetStartupCongfigReturnValue = {
isLoading: false,
isError: false,
data: {
googleLoginEnabled: true,
registrationEnabled: true,
serverDomain: 'mock-server'
}
}
} = {}) => {
const mockUseLoginUser = jest
@ -33,12 +36,16 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue);
const renderResult = render(<Login />);
return {
...renderResult,
mockUseLoginUser,
mockUseGetUserQuery
mockUseGetUserQuery,
mockUseGetStartupConfig
};
};

View file

@ -3,11 +3,6 @@ import userEvent from '@testing-library/user-event';
import Registration from '../Registration';
import * as mockDataProvider from '~/data-provider';
jest.mock('~/utils/envConstants', () => ({
DOMAIN_SERVER: 'mock-server',
SHOW_GOOGLE_LOGIN_OPTION: true
}));
jest.mock('~/data-provider');
const setup = ({
@ -22,6 +17,15 @@ const setup = ({
mutate: jest.fn(),
data: {},
isSuccess: false
},
useGetStartupCongfigReturnValue = {
isLoading: false,
isError: false,
data: {
googleLoginEnabled: true,
registrationEnabled: true,
serverDomain: 'mock-server'
}
}
} = {}) => {
const mockUseRegisterUserMutation = jest
@ -32,13 +36,18 @@ const setup = ({
.spyOn(mockDataProvider, 'useGetUserQuery')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetUserQueryReturnValue);
const mockUseGetStartupConfig = jest
.spyOn(mockDataProvider, 'useGetStartupConfig')
//@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult
.mockReturnValue(useGetStartupCongfigReturnValue);
const renderResult = render(<Registration />);
return {
...renderResult,
mockUseRegisterUserMutation,
mockUseGetUserQuery
mockUseGetUserQuery,
mockUseGetStartupConfig
};
};

View file

@ -1,6 +1,8 @@
import React from 'react';
import { useGetStartupConfig } from '~/data-provider';
export default function Footer() {
const { data: config } = useGetStartupConfig();
return (
<div className="hidden px-3 pb-1 pt-2 text-center text-xs text-black/50 dark:text-white/50 md:block md:px-4 md:pb-4 md:pt-3">
<a
@ -9,7 +11,7 @@ export default function Footer() {
rel="noreferrer"
className="underline"
>
{import.meta.env.VITE_APP_TITLE || 'LibreChat'}
{config?.appTitle || 'LibreChat'}
</a>
. Serves and searches all conversations reliably. All AI convos under one house. Pay per call
and not per month (cents compared to dollars).

View file

@ -1,20 +1,24 @@
import React from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import useDocumentTitle from '~/hooks/useDocumentTitle';
import SunIcon from '../svg/SunIcon';
import LightningIcon from '../svg/LightningIcon';
import CautionIcon from '../svg/CautionIcon';
import store from '~/store';
import { useGetStartupConfig } from '~/data-provider';
export default function Landing() {
const { data: config } = useGetStartupConfig();
const setText = useSetRecoilState(store.text);
const conversation = useRecoilValue(store.conversation);
// @ts-ignore TODO: Fix anti-pattern - requires refactoring conversation store
const { title = 'New Chat' } = conversation || {};
useDocumentTitle(title);
const clickHandler = (e) => {
const clickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const { innerText } = e.target;
const { innerText } = e.target as HTMLButtonElement;
const quote = innerText.split('"')[1].trim();
setText(quote);
};
@ -26,7 +30,7 @@ export default function Landing() {
id="landing-title"
className="mb-10 ml-auto mr-auto mt-6 flex items-center justify-center gap-2 text-center text-4xl font-semibold sm:mb-16 md:mt-[10vh]"
>
{import.meta.env.VITE_APP_TITLE || 'LibreChat'}
{config?.appTitle || 'LibreChat'}
</h1>
<div className="items-start gap-3.5 text-center md:flex">
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">

View file

@ -89,3 +89,7 @@ export const resetPassword = () => {
export const plugins = () => {
return '/api/plugins';
};
export const config = () => {
return '/api/config';
}

View file

@ -111,3 +111,7 @@ export const getAvailablePlugins = (): Promise<t.TPlugin[]> => {
export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => {
return request.post(endpoints.userPlugins(), payload);
};
export const getStartupConfig = (): Promise<t.TStartupConfig> => {
return request.get(endpoints.config());
}

View file

@ -19,7 +19,8 @@ export enum QueryKeys {
presets = 'presets',
searchResults = 'searchResults',
tokenCount = 'tokenCount',
availablePlugins = 'availablePlugins'
availablePlugins = 'availablePlugins',
startupConfig = 'startupConfig',
}
export const useAbortRequestWithMessage = (): UseMutationResult<
@ -336,3 +337,11 @@ export const useUpdateUserPluginsMutation = (): UseMutationResult<
}
});
};
export const useGetStartupConfig = (): QueryObserverResult<t.TStartupConfig> => {
return useQuery<t.TStartupConfig>([QueryKeys.startupConfig], () => dataService.getStartupConfig(), {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false
});
}

View file

@ -233,3 +233,10 @@ export type TResetPassword = {
token: string;
password: string;
};
export type TStartupConfig = {
appTitle: boolean;
googleLoginEnabled: boolean;
serverDomain: string;
registrationEnabled: boolean;
}

View file

@ -7,7 +7,11 @@ import Messages from '../components/Messages';
import TextChat from '../components/Input';
import store from '~/store';
import { useGetMessagesByConvoId, useGetConversationByIdMutation } from '~/data-provider';
import {
useGetMessagesByConvoId,
useGetConversationByIdMutation,
useGetStartupConfig
} from '~/data-provider';
export default function Chat() {
const searchQuery = useRecoilValue(store.searchQuery);
@ -21,6 +25,7 @@ export default function Chat() {
//disabled by default, we only enable it when messagesTree is null
const messagesQuery = useGetMessagesByConvoId(conversationId, { enabled: false });
const getConversationMutation = useGetConversationByIdMutation(conversationId);
const { data: config } = useGetStartupConfig();
// when conversation changed or conversationId (in url) changed
useEffect(() => {
@ -53,8 +58,8 @@ export default function Chat() {
// conversationId (in url) should always follow conversation?.conversationId, unless conversation is null
navigate(`/chat/${conversation?.conversationId}`);
}
document.title = conversation?.title || import.meta.env.VITE_APP_TITLE || 'Chat';
}, [conversation, conversationId]);
document.title = conversation?.title || config?.appTitle || 'Chat';
}, [conversation, conversationId, config]);
useEffect(() => {
if (messagesTree === null && conversation?.conversationId) {

View file

@ -5,7 +5,6 @@ import Search from './Search';
import { Login, Registration, RequestPasswordReset, ResetPassword } from '../components/Auth';
import { AuthContextProvider } from '../hooks/AuthContext';
import ApiErrorWatcher from '../components/Auth/ApiErrorWatcher';
import { ALLOW_REGISTRATION } from '../utils/envConstants';
const AuthLayout = () => (
<AuthContextProvider>
@ -17,7 +16,7 @@ const AuthLayout = () => (
export const router = createBrowserRouter([
{
path: 'register',
element: ALLOW_REGISTRATION ? <Registration /> : <Navigate to="/login" replace={true} />
element: <Registration />
},
{
path: 'forgot-password',

View file

@ -1,9 +0,0 @@
const ALLOW_REGISTRATION = import.meta.env.ALLOW_REGISTRATION === 'true';
const DOMAIN_SERVER = import.meta.env.DOMAIN_SERVER;
const SHOW_GOOGLE_LOGIN_OPTION = import.meta.env.VITE_SHOW_GOOGLE_LOGIN_OPTION === 'true';
export {
ALLOW_REGISTRATION,
DOMAIN_SERVER,
SHOW_GOOGLE_LOGIN_OPTION
};

View file

@ -60,7 +60,7 @@ let env = {};
const title = await askQuestion(
'Enter the app title (default: "LibreChat"): '
);
env['VITE_APP_TITLE'] = title || 'LibreChat';
env['APP_TITLE'] = title || 'LibreChat';
// Ask for OPENAI_API_KEY
const key = await askQuestion(

View file

@ -95,6 +95,7 @@ const removeEnvs = {
'SERVER_URL_PROD': 'remove',
'JWT_SECRET_DEV': 'remove', // Lets regen
'JWT_SECRET_PROD': 'remove', // Lets regen
'VITE_APP_TITLE': 'remove',
// Comments to remove:
'#JWT:': 'remove',
'# Add a secure secret for production if deploying to live domain.': 'remove',
@ -120,11 +121,10 @@ loader.addSecureEnvVar(rootEnvPath, 'JWT_SECRET', 32);
// Lets update the openai key name, not the best spot in the env file but who cares ¯\_(ツ)_/¯
loader.writeEnvFile(rootEnvPath, {'OPENAI_API_KEY': initEnv['OPENAI_KEY']})
// TODO: we need to copy over the value of: VITE_SHOW_GOOGLE_LOGIN_OPTION & VITE_APP_TITLE
// TODO: we need to copy over the value of: APP_TITLE
fs.appendFileSync(rootEnvPath, '\n\n##########################\n# Frontend Vite Variables:\n##########################\n');
const frontend = {
'VITE_APP_TITLE': initEnv['VITE_APP_TITLE'] || '"LibreChat"',
'VITE_SHOW_GOOGLE_LOGIN_OPTION': initEnv['VITE_SHOW_GOOGLE_LOGIN_OPTION'] || 'false',
'APP_TITLE': initEnv['VITE_APP_TITLE'] || '"LibreChat"',
'ALLOW_REGISTRATION': 'true'
}
loader.writeEnvFile(rootEnvPath, frontend)

View file

@ -29,7 +29,7 @@ When the first account is registered, the application will automatically migrate
The application is setup to support OAuth2/Social Login with Google. All of the code is in place for Facebook login as well, but this has not been tested because the setup process with Facebook was honestly just too painful for me to deal with. I plan to add support for other OAuth2 providers including Github and Discord at a later time.
To enable Google login, you must create an application in the [Google Cloud Console](https://cloud.google.com) and provide the client ID and client secret in the `/.env` file, then set `VITE_SHOW_GOOGLE_LOGIN_OPTION=true`.
To enable Google login, you must create an application in the [Google Cloud Console](https://cloud.google.com) and provide the client ID and client secret in the `/.env` file.
### *Instructions for setting up Google login are provided below.*
```

View file

@ -50,11 +50,10 @@ To update LibreChat. enter these commands one after the other from the root dir:
- MEILI_HOST=http://meilisearch:7700
- MEILI_HTTP_ADDR=meilisearch:7700
```
- If you'd like to change the app title or disable/enable google login, edit the following lines (the ones in your .env file are not read during building)
- If you'd like to change the app title, edit the following lines (the ones in your .env file are not read during building)
```yaml
args:
VITE_APP_TITLE: LibreChat # default, change to your desired app name
VITE_SHOW_GOOGLE_LOGIN_OPTION: false # default, change to true if you have google auth setup
APP_TITLE: LibreChat # default, change to your desired app name
```
- If for some reason you're not able to build the app image, you can pull the latest image from **Dockerhub**.
@ -67,8 +66,7 @@ To update LibreChat. enter these commands one after the other from the root dir:
context: .
target: node
args:
VITE_APP_TITLE: LibreChat # default, change to your desired app name
VITE_SHOW_GOOGLE_LOGIN_OPTION: false # default, change to true if you have google auth setup
APP_TITLE: LibreChat # default, change to your desired app name
```
- Comment this line in (remove the `#` key)

255
package-lock.json generated
View file

@ -81,7 +81,8 @@
"devDependencies": {
"jest": "^29.5.0",
"nodemon": "^2.0.20",
"path": "^0.12.7"
"path": "^0.12.7",
"supertest": "^6.3.3"
}
},
"api/node_modules/ansi-styles": {
@ -8340,6 +8341,12 @@
"node": ">=8"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
@ -9883,6 +9890,12 @@
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true
},
"node_modules/component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -10013,6 +10026,12 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
@ -10638,6 +10657,16 @@
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -12087,6 +12116,12 @@
"node": ">=6"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true
},
"node_modules/fast-text-encoding": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz",
@ -12486,6 +12521,21 @@
"node": ">=0.4.x"
}
},
"node_modules/formidable": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
"integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==",
"dev": true,
"dependencies": {
"dezalgo": "^1.0.4",
"hexoid": "^1.0.0",
"once": "^1.4.0",
"qs": "^6.11.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -13273,6 +13323,15 @@
"he": "bin/he"
}
},
"node_modules/hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
"integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/highlight.js": {
"version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
@ -23640,6 +23699,72 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/superagent": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz",
"integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==",
"dev": true,
"dependencies": {
"component-emitter": "^1.3.0",
"cookiejar": "^2.1.4",
"debug": "^4.3.4",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.0",
"formidable": "^2.1.2",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.11.0",
"semver": "^7.3.8"
},
"engines": {
"node": ">=6.4.0 <13 || >=14"
}
},
"node_modules/superagent/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/superagent/node_modules/semver": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
"integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/superagent/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/superjson": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.3.tgz",
@ -23652,6 +23777,19 @@
"node": ">=10"
}
},
"node_modules/supertest": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.3.tgz",
"integrity": "sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA==",
"dev": true,
"dependencies": {
"methods": "^1.1.2",
"superagent": "^8.0.5"
},
"engines": {
"node": ">=6.4.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -29339,7 +29477,8 @@
"path": "^0.12.7",
"pino": "^8.12.1",
"sanitize": "^2.1.2",
"sharp": "^0.32.1"
"sharp": "^0.32.1",
"supertest": "^6.3.3"
},
"dependencies": {
"ansi-styles": {
@ -31371,6 +31510,12 @@
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="
},
"asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true
},
"asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
@ -32534,6 +32679,12 @@
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true
},
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -32647,6 +32798,12 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true
},
"copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
@ -33104,6 +33261,16 @@
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"requires": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -34216,6 +34383,12 @@
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.2.0.tgz",
"integrity": "sha512-zaTadChr+NekyzallAMXATXLOR8MNx3zqpZ0MUF2aGf4EathnG0f32VLODNlY8IuGY3HoRO2L6/6fSzNsLaHIw=="
},
"fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true
},
"fast-text-encoding": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz",
@ -34512,6 +34685,18 @@
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="
},
"formidable": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
"integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==",
"dev": true,
"requires": {
"dezalgo": "^1.0.4",
"hexoid": "^1.0.0",
"once": "^1.4.0",
"qs": "^6.11.0"
}
},
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -35090,6 +35275,12 @@
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true
},
"hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
"integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
"dev": true
},
"highlight.js": {
"version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
@ -42241,6 +42432,56 @@
}
}
},
"superagent": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz",
"integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==",
"dev": true,
"requires": {
"component-emitter": "^1.3.0",
"cookiejar": "^2.1.4",
"debug": "^4.3.4",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.0",
"formidable": "^2.1.2",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.11.0",
"semver": "^7.3.8"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true
},
"semver": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
"integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
}
}
},
"superjson": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-1.12.3.tgz",
@ -42250,6 +42491,16 @@
"copy-anything": "^3.0.2"
}
},
"supertest": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.3.tgz",
"integrity": "sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA==",
"dev": true,
"requires": {
"methods": "^1.1.2",
"superagent": "^8.0.5"
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",