Fixed docker build.

This commit is contained in:
SamTV12345 2024-03-09 22:32:04 +01:00
parent 4d97c3c48f
commit fa5aed489f
36 changed files with 243 additions and 967 deletions

View file

@ -24,3 +24,4 @@ Dockerfile
settings.json settings.json
src/node_modules src/node_modules
admin/node_modules

View file

@ -54,6 +54,12 @@ jobs:
- -
name: Install all dependencies and symlink for ep_etherpad-lite name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh run: bin/installDeps.sh
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
- -
name: Run the backend tests name: Run the backend tests
run: pnpm test run: pnpm test
@ -105,6 +111,12 @@ jobs:
- -
name: Install all dependencies and symlink for ep_etherpad-lite name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh run: bin/installDeps.sh
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
- -
name: Install Etherpad plugins name: Install Etherpad plugins
run: > run: >
@ -163,6 +175,12 @@ jobs:
- -
name: Install all dependencies and symlink for ep_etherpad-lite name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installOnWindows.bat run: bin/installOnWindows.bat
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
- -
name: Fix up the settings.json name: Fix up the settings.json
run: | run: |
@ -207,6 +225,12 @@ jobs:
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Only install direct dependencies - name: Only install direct dependencies
run: pnpm config set auto-install-peers false run: pnpm config set auto-install-peers false
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
- -
name: Install Etherpad plugins name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm # The --legacy-peer-deps flag is required to work around a bug in npm

View file

@ -12,7 +12,6 @@ jobs:
name: with plugins name: with plugins
runs-on: ubuntu-latest runs-on: ubuntu-latest
# node: [16, 19, 20] >> Disabled node 16 and 18 because they do not work
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -83,11 +82,11 @@ jobs:
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json" run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json"
- -
name: increase maxHttpBufferSize name: increase maxHttpBufferSize
run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 100000/' settings.json" run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 10000000/' settings.json"
- -
name: Disable import/export rate limiting name: Disable import/export rate limiting
run: | run: |
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i settings.json sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json
- -
name: Remove standard frontend test files, so only admin tests are run name: Remove standard frontend test files, so only admin tests are run
run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ out/
/src/bin/node.exe /src/bin/node.exe
plugin_packages plugin_packages
pnpm-lock.yaml pnpm-lock.yaml
/src/templates/admin

View file

@ -4,6 +4,13 @@
# #
# Author: muxator # Author: muxator
FROM node:alpine as adminBuild
WORKDIR /opt/etherpad-lite
COPY ./admin ./admin
RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist
FROM node:alpine as build FROM node:alpine as build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite" LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite"
@ -99,6 +106,7 @@ COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./
FROM build as development FROM build as development
COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/ COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/
COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \ RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } pnpm install --workspace-root ${ETHERPAD_PLUGINS}; }
@ -109,6 +117,7 @@ ENV NODE_ENV=production
ENV ETHERPAD_PRODUCTION=true ENV ETHERPAD_PRODUCTION=true
COPY --chown=etherpad:etherpad ./src ./src COPY --chown=etherpad:etherpad ./src ./src
COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \ RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } && \ pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } && \

View file

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Etherpad Admin Dashboard</title>
<link rel="icon" href="/favicon.ico">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

View file

@ -2,26 +2,38 @@ import {useEffect} from 'react'
import './App.css' import './App.css'
import {connect} from 'socket.io-client' import {connect} from 'socket.io-client'
import {isJSONClean} from './utils/utils.ts' import {isJSONClean} from './utils/utils.ts'
import {NavLink, Outlet} from "react-router-dom"; import {NavLink, Outlet, useNavigate} from "react-router-dom";
import {useStore} from "./store/store.ts"; import {useStore} from "./store/store.ts";
import {LoadingScreen} from "./utils/LoadingScreen.tsx"; import {LoadingScreen} from "./utils/LoadingScreen.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {Trans, useTranslation} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
const WS_URL = import.meta.env.DEV? 'http://localhost:9001' : ''
export const App = ()=> { export const App = ()=> {
const setSettings = useStore(state => state.setSettings); const setSettings = useStore(state => state.setSettings);
const {t} = useTranslation() const {t} = useTranslation()
const navigate = useNavigate()
useEffect(() => {
fetch('/admin-auth/', {
method: 'POST'
}).then((value)=>{
if(!value.ok){
navigate('/login')
}
}).catch(()=>{
navigate('/login')
})
}, []);
useEffect(() => { useEffect(() => {
document.title = t('admin.page-title') document.title = t('admin.page-title')
useStore.getState().setShowLoading(true); useStore.getState().setShowLoading(true);
const settingSocket = connect('http://localhost:9001/settings', { const settingSocket = connect(`${WS_URL}/settings`, {
transports: ['websocket'], transports: ['websocket'],
}); });
const pluginsSocket = connect('http://localhost:9001/pluginfw/installer', { const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {
transports: ['websocket'], transports: ['websocket'],
}) })
@ -74,7 +86,6 @@ export const App = ()=> {
return <div id="wrapper"> return <div id="wrapper">
<LoadingScreen/> <LoadingScreen/>
<ToastDialog/>
<div className="menu"> <div className="menu">
<h1>Etherpad</h1> <h1>Etherpad</h1>
<ul> <ul>

View file

@ -339,6 +339,25 @@ pre {
} }
.dialog-confirm-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.dialog-confirm-content {
position: fixed;
top: 50%;
left: 50%;
background-color: white;
transform: translate(-50%, -50%);
padding: 20px;
z-index: 101;
}
.dialog-content { .dialog-content {
position: fixed; position: fixed;
top: 50%; top: 50%;
@ -463,3 +482,8 @@ pre {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px gap: 20px
} }
.search-field {
width: 50%;
padding: 5px;
}

View file

@ -28,17 +28,17 @@ const LazyImportPlugin: BackendModule = {
try { try {
json = JSON.parse(await localeJSON.text()) json = JSON.parse(await localeJSON.text())
} catch(e) { } catch(e) {
callback(true, null); callback(new Error("Error loading"), null);
} }
callback(null, json); callback(null, json);
}, },
save: function (language, namespace, data) { save: function () {
}, },
create: function (languages, namespace, key, fallbackValue) { create: function () {
/* save the missing translation */ /* save the missing translation */
}, },
}; };

View file

@ -11,6 +11,7 @@ import * as Toast from '@radix-ui/react-toast'
import {I18nextProvider} from "react-i18next"; import {I18nextProvider} from "react-i18next";
import i18n from "./localization/i18n.ts"; import i18n from "./localization/i18n.ts";
import {PadPage} from "./pages/PadPage.tsx"; import {PadPage} from "./pages/PadPage.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
const router = createBrowserRouter(createRoutesFromElements( const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}> <><Route element={<App/>}>
@ -31,6 +32,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Toast.Provider> <Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/> <RouterProvider router={router}/>
</Toast.Provider> </Toast.Provider>
</I18nextProvider> </I18nextProvider>

View file

@ -10,7 +10,6 @@ export const HelpPage = () => {
useEffect(() => { useEffect(() => {
if(!settingsSocket) return; if(!settingsSocket) return;
settingsSocket?.on('reply:help', (data) => { settingsSocket?.on('reply:help', (data) => {
console.log(data)
setHelpData(data) setHelpData(data)
}); });
@ -19,11 +18,11 @@ export const HelpPage = () => {
const renderHooks = (hooks:Record<string, Record<string, string>>) => { const renderHooks = (hooks:Record<string, Record<string, string>>) => {
return Object.keys(hooks).map((hookName, i) => { return Object.keys(hooks).map((hookName, i) => {
return <div key={i}> return <div key={hookName+i}>
<h3>{hookName}</h3> <h3>{hookName}</h3>
<ul> <ul>
{Object.keys(hooks[hookName]).map((hook, i) => <li>{hook} {Object.keys(hooks[hookName]).map((hook, i) => <li>{hook}
<ul key={i}> <ul key={hookName+hook+i}>
{Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)} {Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}
</ul> </ul>
</li>)} </li>)}

View file

@ -2,7 +2,7 @@ import {useStore} from "../store/store.ts";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts"; import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
import {useDebounce} from "../utils/useDebounce.ts"; import {useDebounce} from "../utils/useDebounce.ts";
import {Trans} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
export const HomePage = () => { export const HomePage = () => {
@ -17,6 +17,7 @@ export const HomePage = () => {
searchTerm: '' searchTerm: ''
}) })
const [searchTerm, setSearchTerm] = useState<string>('') const [searchTerm, setSearchTerm] = useState<string>('')
const {t} = useTranslation()
useEffect(() => { useEffect(() => {
@ -30,8 +31,18 @@ export const HomePage = () => {
setInstalledPlugins(data.installed) setInstalledPlugins(data.installed)
}) })
pluginsSocket.on('results:updatable', () => { pluginsSocket.on('results:updatable', (data) => {
console.log("Finished install") data.updatable.forEach((pluginName: string) => {
setInstalledPlugins(installedPlugins.map(plugin => {
if (plugin.name === pluginName) {
return {
...plugin,
updatable: true
}
}
return plugin
}))
})
}) })
pluginsSocket.on('finished:install', () => { pluginsSocket.on('finished:install', () => {
@ -118,9 +129,15 @@ export const HomePage = () => {
return <tr key={index}> return <tr key={index}>
<td>{plugin.name}</td> <td>{plugin.name}</td>
<td>{plugin.version}</td> <td>{plugin.version}</td>
<td onClick={() => { <td>
}}> {
<button disabled={plugin.name == "ep_etherpad-lite"} onClick={() => uninstallPlugin(plugin.name)}><Trans i18nKey="admin_plugins.installed_uninstall.value"/></button> plugin.updatable ?
<button onClick={() => installPlugin(plugin.name)}>Update</button>
: <button disabled={plugin.name == "ep_etherpad-lite"}
onClick={() => uninstallPlugin(plugin.name)}><Trans
i18nKey="admin_plugins.installed_uninstall.value"/></button>
}
</td> </td>
</tr> </tr>
})} })}
@ -130,7 +147,7 @@ export const HomePage = () => {
<h2><Trans i18nKey="admin_plugins.available"/></h2> <h2><Trans i18nKey="admin_plugins.available"/></h2>
<input type="text" value={searchTerm} onChange={v=>{ <input className="search-field" placeholder={t('admin_plugins.available_search.placeholder')} type="text" value={searchTerm} onChange={v=>{
setSearchTerm(v.target.value) setSearchTerm(v.target.value)
}}/> }}/>

View file

@ -1,17 +1,28 @@
import {useState} from "react"; import {useState} from "react";
import {useStore} from "../store/store.ts";
import {useNavigate} from "react-router-dom";
export const LoginScreen = ()=>{ export const LoginScreen = ()=>{
const navigate = useNavigate()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const login = ()=>{ const login = ()=>{
fetch('/api/auth', { fetch('/admin-auth/', {
method: 'GET', method: 'POST',
headers:{ headers:{
Authorization: `Basic ${btoa(`${username}:${password}`)}` Authorization: `Basic ${btoa(`${username}:${password}`)}`
} }
}).then(r=>{ }).then(r=>{
console.log(r.status) if(!r.ok) {
useStore.getState().setToastState({
open: true,
title: "Login failed",
success: false
})
} else {
navigate('/')
}
}).catch(e=>{ }).catch(e=>{
console.error(e) console.error(e)
}) })

View file

@ -1,9 +1,10 @@
import {Trans} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import {useStore} from "../store/store.ts"; import {useStore} from "../store/store.ts";
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {useDebounce} from "../utils/useDebounce.ts"; import {useDebounce} from "../utils/useDebounce.ts";
import {determineSorting} from "../utils/sorting.ts"; import {determineSorting} from "../utils/sorting.ts";
import * as Dialog from "@radix-ui/react-dialog";
export const PadPage = ()=>{ export const PadPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket) const settingsSocket = useStore(state=>state.settingsSocket)
@ -14,7 +15,7 @@ export const PadPage = ()=>{
sortBy: 'padName', sortBy: 'padName',
ascending: true ascending: true
}) })
const {t} = useTranslation()
const [searchTerm, setSearchTerm] = useState<string>('') const [searchTerm, setSearchTerm] = useState<string>('')
const pads = useStore(state=>state.pads) const pads = useStore(state=>state.pads)
const pages = useMemo(()=>{ const pages = useMemo(()=>{
@ -24,7 +25,9 @@ export const PadPage = ()=>{
const totalPages = Math.ceil(pads!.total / searchParams.limit) const totalPages = Math.ceil(pads!.total / searchParams.limit)
return Array.from({length: totalPages}, (_, i) => i+1) return Array.from({length: totalPages}, (_, i) => i+1)
},[pads]) },[pads, searchParams.limit])
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
const [padToDelete, setPadToDelete] = useState<string>('')
useDebounce(()=>{ useDebounce(()=>{
setSearchParams({ setSearchParams({
@ -64,11 +67,39 @@ export const PadPage = ()=>{
}) })
}, [settingsSocket, pads]); }, [settingsSocket, pads]);
const deletePad = (padID: string)=>{
settingsSocket?.emit('deletePad', padID)
}
return <div> return <div>
<Dialog.Root open={deleteDialog}><Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<div className="">
<div className=""></div>
<div className="">
{t("ep_admin_pads:ep_adminpads2_confirm", {
padID: padToDelete,
})}
</div>
<div className="settings-button-bar">
<button onClick={()=>{
setDeleteDialog(false)
}}>Cancel</button>
<button onClick={()=>{
deletePad(padToDelete)
setDeleteDialog(false)
}}>Ok</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1> <h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<input type="text" value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder="Pads suchen"/> <input type="text" value={searchTerm} onChange={v=>setSearchTerm(v.target.value)}
placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -78,21 +109,21 @@ export const PadPage = ()=>{
sortBy: 'padName', sortBy: 'padName',
ascending: !searchParams.ascending ascending: !searchParams.ascending
}) })
}}>PadId</th> }}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{ <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
setSearchParams({ setSearchParams({
...searchParams, ...searchParams,
sortBy: 'lastEdited', sortBy: 'lastEdited',
ascending: !searchParams.ascending ascending: !searchParams.ascending
}) })
}}>Users</th> }}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{ <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
setSearchParams({ setSearchParams({
...searchParams, ...searchParams,
sortBy: 'userCount', sortBy: 'userCount',
ascending: !searchParams.ascending ascending: !searchParams.ascending
}) })
}}>Last Edited</th> }}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{ <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
setSearchParams({ setSearchParams({
...searchParams, ...searchParams,
@ -100,7 +131,7 @@ export const PadPage = ()=>{
ascending: !searchParams.ascending ascending: !searchParams.ascending
}) })
}}>Revision number</th> }}>Revision number</th>
<th>Actions</th> <th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -114,8 +145,9 @@ export const PadPage = ()=>{
<td> <td>
<div className="settings-button-bar"> <div className="settings-button-bar">
<button onClick={()=>{ <button onClick={()=>{
settingsSocket?.emit('deletePad', pad.padName) setPadToDelete(pad.padName)
}}>delete</button> setDeleteDialog(true)
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/></button>
<button onClick={()=>{ <button onClick={()=>{
window.open(`/p/${pad.padName}`, '_blank') window.open(`/p/${pad.padName}`, '_blank')
}}>view</button> }}>view</button>

View file

@ -12,7 +12,7 @@ export type InstalledPlugin = {
path: string, path: string,
realPath: string, realPath: string,
version:string, version:string,
updatable: boolean updatable?: boolean
} }

View file

@ -1,6 +1,6 @@
import {create} from "zustand"; import {create} from "zustand";
import {Socket} from "socket.io-client"; import {Socket} from "socket.io-client";
import {PadSearchResult, PadType} from "../utils/PadSearch.ts"; import {PadSearchResult} from "../utils/PadSearch.ts";
type ToastState = { type ToastState = {
description?:string, description?:string,

View file

@ -14,14 +14,17 @@ export default defineConfig({
] ]
})], })],
base: '/admin', base: '/admin',
build:{
outDir: '../src/templates/admin'
},
server:{ server:{
proxy: { proxy: {
'/socket.io/': { '/socket.io/*': {
target: 'http://localhost:9001', target: 'http://localhost:9001',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
}, },
'/api/auth': { '/admin-auth/': {
target: 'http://localhost:9001', target: 'http://localhost:9001',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-prox/, '/admin/') rewrite: (path) => path.replace(/^\/admin-prox/, '/admin/')

View file

@ -49,6 +49,13 @@ rm -rf src/node_modules || true
#log "do a normal unix install first..." #log "do a normal unix install first..."
#$(try cd ./bin/installDeps.sh) #$(try cd ./bin/installDeps.sh)
# Install admin frontend
cd admin
try pnpm install
try pnpm run build
cd ..
log "copy the windows settings template..." log "copy the windows settings template..."
try cp settings.json.template settings.json try cp settings.json.template settings.json

View file

@ -1,3 +1,2 @@
packages: packages:
- src - src
- admin

View file

@ -5,8 +5,6 @@ require('eslint-config-etherpad/patch/modern-module-resolution');
module.exports = { module.exports = {
ignorePatterns: [ ignorePatterns: [
'/static/js/admin/jquery.autosize.js',
'/static/js/admin/minify.json.js',
'/static/js/vendors/browser.js', '/static/js/vendors/browser.js',
'/static/js/vendors/farbtastic.js', '/static/js/vendors/farbtastic.js',
'/static/js/vendors/gritter.js', '/static/js/vendors/gritter.js',

View file

@ -92,14 +92,12 @@
{ {
"name": "adminplugins", "name": "adminplugins",
"hooks": { "hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins",
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins" "socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
} }
}, },
{ {
"name": "adminsettings", "name": "adminsettings",
"hooks": { "hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminsettings",
"socketio": "ep_etherpad-lite/node/hooks/express/adminsettings" "socketio": "ep_etherpad-lite/node/hooks/express/adminsettings"
} }
}, },

View file

@ -1,7 +1,9 @@
'use strict'; 'use strict';
import {ArgsExpressType} from "../../types/ArgsExpressType"; import {ArgsExpressType} from "../../types/ArgsExpressType";
import path from "path";
const settings = require('ep_etherpad-lite/node/utils/Settings');
const eejs = require('../../eejs'); const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
/** /**
* Add the admin navigation link * Add the admin navigation link
@ -11,9 +13,19 @@ const eejs = require('../../eejs');
* @return {*} * @return {*}
*/ */
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => { exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => {
args.app.get('/admin', (req:any, res:any) => { args.app.get('/admin/*', (req:any, res:any, next:Function) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/'); if (req.path.includes('.')) {
res.send(eejs.require('ep_etherpad-lite/templates/admin/index.html', {req})); const relativPath = req.path.split('/admin/')[1];
res.sendFile(path.join(ADMIN_PATH, relativPath));
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(ADMIN_PATH, 'index.html'));
}
}); });
args.app.get('/admin', (req:any, res:any, next:Function) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
})
return cb(); return cb();
}; };

View file

@ -12,35 +12,7 @@ const installer = require('../../../static/js/pluginfw/installer');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins'); const plugins = require('../../../static/js/pluginfw/plugins');
const semver = require('semver'); const semver = require('semver');
const UpdateCheck = require('../../utils/UpdateCheck');
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function) => {
args.app.get('/admin/plugins', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins.html', {
plugins: pluginDefs.plugins,
req,
errors: [],
}));
});
args.app.get('/admin/plugins/info', (req:any, res:any) => {
const gitCommit = settings.getGitCommit();
const epVersion = settings.getEpVersion();
res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins-info.html', {
gitCommit,
epVersion,
installedPlugins: `<pre>${plugins.formatPlugins().replace(/, /g, '\n')}</pre>`,
installedParts: `<pre>${plugins.formatParts()}</pre>`,
installedServerHooks: `<div>${plugins.formatHooks('hooks', true)}</div>`,
installedClientHooks: `<div>${plugins.formatHooks('client_hooks', true)}</div>`,
latestVersion: UpdateCheck.getLatestVersion(),
req,
}));
});
return cb();
};
exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => { exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const io = args.io.of('/pluginfw/installer'); const io = args.io.of('/pluginfw/installer');

View file

@ -13,16 +13,6 @@ const UpdateCheck = require('../../utils/UpdateCheck');
const padManager = require('../../db/PadManager'); const padManager = require('../../db/PadManager');
const api = require('../../db/API'); const api = require('../../db/API');
exports.expressCreateServer = (hookName:string, {app}:any) => {
app.get('/admin/settings', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/admin/settings.html', {
req,
settings: '',
errors: [],
}));
});
};
const queryPadLimit = 12; const queryPadLimit = 12;

View file

@ -50,7 +50,7 @@ exports.userCanModify = (padId: string, req: SocketClientRequest) => {
exports.authnFailureDelayMs = 1000; exports.authnFailureDelayMs = 1000;
const checkAccess = async (req:any, res:any, next: Function) => { const checkAccess = async (req:any, res:any, next: Function) => {
const requireAdmin = req.path.toLowerCase().startsWith('/admin'); const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');
// /////////////////////////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
@ -126,7 +126,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// completed, or maybe different credentials are required), go to the next step. // completed, or maybe different credentials are required), go to the next step.
// /////////////////////////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////////////////////////
if (await authorize()) return next(); if (await authorize()) {
if(requireAdmin) {
res.status(200).send('Authorized')
return
}
return next();
}
// /////////////////////////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different // Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different
@ -163,7 +169,7 @@ const checkAccess = async (req:any, res:any, next: Function) => {
if (await aCallFirst0('authnFailure', {req, res})) return; if (await aCallFirst0('authnFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return; if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authentication failure. Fall back to basic authentication. // No plugin handled the authentication failure. Fall back to basic authentication.
res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); //res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
// Delay the error response for 1s to slow down brute force attacks. // Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs)); await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
res.status(401).send('Authentication Required'); res.status(401).send('Authentication Required');
@ -188,7 +194,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// a login page). // a login page).
// /////////////////////////////////////////////////////////////////////////////////////////////// // ///////////////////////////////////////////////////////////////////////////////////////////////
if (await authorize()) return next(); const auth = await authorize()
if (auth && !requireAdmin) return next();
if(auth && requireAdmin) {
res.status(200).send('Authorized')
return
}
if (await aCallFirst0('authzFailure', {req, res})) return; if (await aCallFirst0('authzFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return; if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authorization failure. // No plugin handled the authorization failure.

View file

@ -1,180 +0,0 @@
// Autosize 1.13 - jQuery plugin for textareas
// (c) 2012 Jack Moore - jacklmoore.com
// license: www.opensource.org/licenses/mit-license.php
(function ($) {
var
defaults = {
className: 'autosizejs',
append: "",
callback: false
},
hidden = 'hidden',
borderBox = 'border-box',
lineHeight = 'lineHeight',
copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>',
// line-height is omitted because IE7/IE8 doesn't return the correct value.
copyStyle = [
'fontFamily',
'fontSize',
'fontWeight',
'fontStyle',
'letterSpacing',
'textTransform',
'wordSpacing',
'textIndent'
],
oninput = 'oninput',
onpropertychange = 'onpropertychange',
test = $(copy)[0];
// For testing support in old FireFox
test.setAttribute(oninput, "return");
if ($.isFunction(test[oninput]) || onpropertychange in test) {
// test that line-height can be accurately copied to avoid
// incorrect value reporting in old IE and old Opera
$(test).css(lineHeight, '99px');
if ($(test).css(lineHeight) === '99px') {
copyStyle.push(lineHeight);
}
$.fn.autosize = function (options) {
options = $.extend({}, defaults, options || {});
return this.each(function () {
var
ta = this,
$ta = $(ta),
mirror,
minHeight = $ta.height(),
maxHeight = parseInt($ta.css('maxHeight'), 10),
active,
i = copyStyle.length,
resize,
boxOffset = 0,
value = ta.value,
callback = $.isFunction(options.callback);
if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){
boxOffset = $ta.outerHeight() - $ta.height();
}
if ($ta.data('mirror') || $ta.data('ismirror')) {
// if autosize has already been applied, exit.
// if autosize is being applied to a mirror element, exit.
return;
} else {
mirror = $(copy).data('ismirror', true).addClass(options.className)[0];
resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
$ta.data('mirror', $(mirror)).css({
overflow: hidden,
overflowY: hidden,
wordWrap: 'break-word',
resize: resize
});
}
// Opera returns '-1px' when max-height is set to 'none'.
maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
// Using mainly bare JS in this function because it is going
// to fire very often while typing, and needs to very efficient.
function adjust() {
var height, overflow, original;
// the active flag keeps IE from tripping all over itself. Otherwise
// actions in the adjust function will cause IE to call adjust again.
if (!active) {
active = true;
mirror.value = ta.value + options.append;
mirror.style.overflowY = ta.style.overflowY;
original = parseInt(ta.style.height,10);
// Update the width in case the original textarea width has changed
mirror.style.width = $ta.css('width');
// Needed for IE to reliably return the correct scrollHeight
mirror.scrollTop = 0;
// Set a very high value for scrollTop to be sure the
// mirror is scrolled all the way to the bottom.
mirror.scrollTop = 9e4;
height = mirror.scrollTop;
overflow = hidden;
if (height > maxHeight) {
height = maxHeight;
overflow = 'scroll';
} else if (height < minHeight) {
height = minHeight;
}
height += boxOffset;
ta.style.overflowY = overflow;
if (original !== height) {
ta.style.height = height + 'px';
if (callback) {
options.callback.call(ta);
}
}
// This small timeout gives IE a chance to draw it's scrollbar
// before adjust can be run again (prevents an infinite loop).
setTimeout(function () {
active = false;
}, 1);
}
}
// mirror is a duplicate textarea located off-screen that
// is automatically updated to contain the same text as the
// original textarea. mirror always has a height of 0.
// This gives a cross-browser supported way getting the actual
// height of the text, through the scrollTop property.
while (i--) {
mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
}
$('body').append(mirror);
if (onpropertychange in ta) {
if (oninput in ta) {
// Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
// so binding to onkeyup to catch most of those occassions. There is no way that I
// know of to detect something like 'cut' in IE9.
ta[oninput] = ta.onkeyup = adjust;
} else {
// IE7 / IE8
ta[onpropertychange] = adjust;
}
} else {
// Modern Browsers
ta[oninput] = adjust;
// The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
// This is a hack to get Chrome to reflow it's text.
ta.value = '';
ta.value = value;
}
$(window).resize(adjust);
// Allow for manual triggering if needed.
$ta.on('autosize', adjust);
// Call adjust in case the textarea already contains text.
adjust();
});
};
} else {
// Makes no changes for older browsers (FireFox3- and Safari4-)
$.fn.autosize = function (callback) {
return this;
};
}
}(jQuery));

View file

@ -1,61 +0,0 @@
/*! JSON.minify()
v0.1 (c) Kyle Simpson
MIT License
*/
(function(global){
if (typeof global.JSON == "undefined" || !global.JSON) {
global.JSON = {};
}
global.JSON.minify = function(json) {
var tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g,
in_string = false,
in_multiline_comment = false,
in_singleline_comment = false,
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc
;
tokenizer.lastIndex = 0;
while (tmp = tokenizer.exec(json)) {
lc = RegExp.leftContext;
rc = RegExp.rightContext;
if (!in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.substring(from);
if (!in_string) {
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,"");
}
new_str[ns++] = tmp2;
}
from = tokenizer.lastIndex;
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/);
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
in_string = !in_string;
}
from--; // include " character in next catch
rc = json.substring(from);
}
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = true;
}
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = false;
}
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = true;
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
}
new_str[ns++] = rc;
return new_str.join("");
};
})(this);

View file

@ -1,273 +0,0 @@
'use strict';
/* global socketio */
$(document).ready(() => {
const socket = socketio.connect('..', '/pluginfw/installer');
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
const search = (searchTerm, limit) => {
if (search.searchTerm !== searchTerm) {
search.offset = 0;
search.results = [];
search.end = false;
}
limit = limit ? limit : search.limit;
search.searchTerm = searchTerm;
socket.emit('search', {
searchTerm,
offset: search.offset,
limit,
sortBy: search.sortBy,
sortDir: search.sortDir,
});
search.offset += limit;
$('#search-progress').show();
search.messages.show('fetching');
search.searching = true;
};
search.searching = false;
search.offset = 0;
search.limit = 999;
search.results = [];
search.sortBy = 'name';
search.sortDir = /* DESC?*/true;
search.end = true;// have we received all results already?
search.messages = {
show: (msg) => {
// $('.search-results .messages').show()
$(`.search-results .messages .${msg}`).show();
$(`.search-results .messages .${msg} *`).show();
},
hide: (msg) => {
$('.search-results .messages').hide();
$(`.search-results .messages .${msg}`).hide();
$(`.search-results .messages .${msg} *`).hide();
},
};
const installed = {
progress: {
show: (plugin, msg) => {
$(`.installed-results .${plugin} .progress`).show();
$(`.installed-results .${plugin} .progress .message`).text(msg);
if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
$(window).scrollTop($(`.${plugin}`).offset().top - 100);
}
},
hide: (plugin) => {
$(`.installed-results .${plugin} .progress`).hide();
$(`.installed-results .${plugin} .progress .message`).text('');
},
},
messages: {
show: (msg) => {
$('.installed-results .messages').show();
$(`.installed-results .messages .${msg}`).show();
},
hide: (msg) => {
$('.installed-results .messages').hide();
$(`.installed-results .messages .${msg}`).hide();
},
},
list: [],
};
const displayPluginList = (plugins, container, template) => {
plugins.forEach((plugin) => {
const row = template.clone();
for (const attr in plugin) {
if (attr === 'name') { // Hack to rewrite URLS into name
const link = $('<a>')
.attr('href', `https://npmjs.org/package/${plugin.name}`)
.attr('plugin', 'Plugin details')
.attr('rel', 'noopener noreferrer')
.attr('target', '_blank')
.text(plugin.name.substr(3));
row.find('.name').append(link);
} else {
row.find(`.${attr}`).text(plugin[attr]);
}
}
row.find('.version').text(plugin.version);
row.addClass(plugin.name);
row.data('plugin', plugin.name);
container.append(row);
});
updateHandlers();
};
const sortPluginList = (plugins, property, /* ASC?*/dir) => plugins.sort((a, b) => {
if (a[property] < b[property]) return dir ? -1 : 1;
if (a[property] > b[property]) return dir ? 1 : -1;
// a must be equal to b
return 0;
});
const updateHandlers = () => {
// Search
$('#search-query').off('keyup').on('keyup', () => {
search($('#search-query').val());
});
// Prevent form submit
$('#search-query').parent().on('submit', () => false);
// update & install
$('.do-install, .do-update').off('click').on('click', function (e) {
const $row = $(e.target).closest('tr');
const plugin = $row.data('plugin');
if ($(this).hasClass('do-install')) {
$row.remove().appendTo('#installed-plugins');
installed.progress.show(plugin, 'Installing');
} else {
installed.progress.show(plugin, 'Updating');
}
socket.emit('install', plugin);
installed.messages.hide('nothing-installed');
});
// uninstall
$('.do-uninstall').off('click').on('click', (e) => {
const $row = $(e.target).closest('tr');
const pluginName = $row.data('plugin');
socket.emit('uninstall', pluginName);
installed.progress.show(pluginName, 'Uninstalling');
installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
});
// Sort
$('.sort.up').off('click').on('click', function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = false;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
$('.sort.down, .sort.none').off('click').on('click', function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = true;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
};
socket.on('results:search', (data) => {
if (!data.results.length) search.end = true;
if (data.query.offset === 0) search.results = [];
search.messages.hide('nothing-found');
search.messages.hide('fetching');
$('#search-query').prop('disabled', false);
console.log('got search results', data);
// add to results
search.results = search.results.concat(data.results);
// Update sorting head
$('.sort')
.removeClass('up down')
.addClass('none');
$(`.search-results thead th[data-label=${data.query.sortBy}]`)
.removeClass('none')
.addClass(data.query.sortDir ? 'up' : 'down');
// re-render search results
const searchWidget = $('.search-results');
searchWidget.find('.results *').remove();
if (search.results.length > 0) {
displayPluginList(
search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
} else {
search.messages.show('nothing-found');
}
search.messages.hide('fetching');
$('#search-progress').hide();
search.searching = false;
});
socket.on('results:installed', (data) => {
installed.messages.hide('fetching');
installed.messages.hide('nothing-installed');
installed.list = data.installed;
sortPluginList(installed.list, 'name', /* ASC?*/true);
// filter out epl
installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
// remove all installed plugins (leave plugins that are still being installed)
installed.list.forEach((plugin) => {
$(`#installed-plugins .${plugin.name}`).remove();
});
if (installed.list.length > 0) {
displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
socket.emit('checkUpdates');
} else {
installed.messages.show('nothing-installed');
}
});
socket.on('results:updatable', (data) => {
data.updatable.forEach((pluginName) => {
const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
actions.find('.do-update').remove();
actions.append(
$('<input>').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
});
updateHandlers();
});
socket.on('finished:install', (data) => {
if (data.error) {
if (data.code === 'EPEERINVALID') {
alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
}
alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
$(`#installed-plugins .${data.plugin}`).remove();
}
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('finished:uninstall', (data) => {
if (data.error) {
alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`);
}
// remove plugin from installed list
$(`#installed-plugins .${data.plugin}`).remove();
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('connect', () => {
updateHandlers();
socket.emit('getInstalled');
search.searchTerm = null;
search($('#search-query').val());
});
// check for updates every 5mins
setInterval(() => {
socket.emit('checkUpdates');
}, 1000 * 60 * 5);
});

View file

@ -1,69 +0,0 @@
'use strict';
$(document).ready(() => {
const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => {
socket.emit('load');
});
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
socket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
if (settings.results === 'NOT_ALLOWED') {
$('.innerwrapper').hide();
$('.innerwrapper-err').show();
$('.err-message').html('Settings json is not authorized to be viewed in Admin page!!');
return;
}
/* Check to make sure the JSON is clean before proceeding */
if (isJSONClean(settings.results)) {
$('.settings').append(settings.results);
$('.settings').trigger('focus');
$('.settings').autosize();
} else {
alert('Invalid JSON');
}
});
/* When the admin clicks save Settings check the JSON then send the JSON back to the server */
$('#saveSettings').on('click', () => {
const editedSettings = $('.settings').val();
if (isJSONClean(editedSettings)) {
// JSON is clean so emit it to the server
socket.emit('saveSettings', $('.settings').val());
} else {
alert('Invalid JSON');
$('.settings').trigger('focus');
}
});
/* Tell Etherpad Server to restart */
$('#restartEtherpad').on('click', () => {
socket.emit('restartServer');
});
socket.on('saveprogress', (progress) => {
$('#response').show();
$('#response').text(progress);
$('#response').fadeOut('slow');
});
});
const isJSONClean = (data) => {
let cleanSettings = JSON.minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try {
return typeof JSON.parse(cleanSettings) === 'object';
} catch (e) {
return false; // the JSON failed to be parsed
}
};

View file

@ -1,28 +0,0 @@
<!doctype html>
<html>
<head>
<title data-l10n-id="admin.page-title">Admin Dashboard - Etherpad</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="../static/css/admin.css">
<script src="../static/js/vendors/jquery.js"></script>
<script src="../socket.io/socket.io.js"></script>
<link rel="localizations" type="application/l10n+json" href="../locales.json" />
<script src="../static/js/vendors/html10n.js"></script>
<script src="../static/js/l10n.js"></script>
</head>
<body>
<div id="wrapper">
<div class="menu">
<h1><a href="../">Etherpad</a></h1>
<ul>
<% e.begin_block("adminMenu"); %>
<li><a href="plugins" data-l10n-id="admin_plugins">Plugin manager</a></li>
<li><a href="settings" data-l10n-id="admin_settings">Settings</a></li>
<li><a href="plugins/info" data-l10n-id="admin_plugins_info">Troubleshooting information</a></li>
<% e.end_block(); %>
</ul>
</div>
</div>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
</body>
</html>

View file

@ -1,47 +0,0 @@
<!doctype html>
<html>
<head>
<title data-l10n-id="admin_plugins_info.page-title">Plugin information - Etherpad</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="../../static/css/admin.css">
<link rel="localizations" type="application/l10n+json" href="../../locales.json" />
<script src="../../static/js/vendors/html10n.js"></script>
<script src="../../static/js/l10n.js"></script>
</head>
<body>
<div id="wrapper">
<div class="menu">
<h1><a href="../../">Etherpad</a></h1>
<ul>
<% e.begin_block("adminMenu"); %>
<li><a href="../plugins" data-l10n-id="admin_plugins">Plugin manager</a></li>
<li><a href="../settings" data-l10n-id="admin_settings">Settings</a></li>
<li><a href="../plugins/info" data-l10n-id="admin_plugins_info">Troubleshooting information</a></li>
<% e.end_block(); %>
</ul>
</div>
<div class="innerwrapper">
<h2 data-l10n-id="admin_plugins_info.version">Etherpad version</h2>
<p><span data-l10n-id="admin_plugins_info.version_number">Version number</span>: <%= epVersion %></p>
<p><span data-l10n-id="admin_plugins_info.version_latest">Latest available version</span>: <%= latestVersion %></p>
<p>Git sha: <a href='https://github.com/ether/etherpad-lite/commit/<%= gitCommit %>'><%= gitCommit %></a></p>
<h2 data-l10n-id="admin_plugins_info.plugins">Installed plugins</h2>
<%- installedPlugins %>
<h2 data-l10n-id="admin_plugins_info.parts">Installed parts</h2>
<%- installedParts %>
<h2 data-l10n-id="admin_plugins_info.hooks">Installed hooks</h2>
<h3 data-l10n-id="admin_plugins_info.hooks_server">Server-side hooks</h3>
<%- installedServerHooks %>
<h3 data-l10n-id="admin_plugins_info.hooks_client">Client-side hooks</h3>
<%- installedClientHooks %>
</div>
</div>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
</body>
</html>

View file

@ -1,121 +0,0 @@
<!doctype html>
<html>
<head>
<title data-l10n-id="admin_plugins.page-title">Plugin manager - Etherpad</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="../static/css/admin.css">
<script src="../static/js/vendors/jquery.js"></script>
<script src="../socket.io/socket.io.js"></script>
<script src="../static/js/socketio.js"></script>
<script src="../static/js/admin/plugins.js"></script>
<link rel="localizations" type="application/l10n+json" href="../locales.json" />
<script src="../static/js/vendors/html10n.js"></script>
<script src="../static/js/l10n.js"></script>
</head>
<body>
<div id="wrapper">
<% if (errors.length) { %>
<div class="errors">
<% errors.forEach(function (item) { %>
<div class="error"><%= item.toString() %></div>
<% }) %>
</div>
<% } %>
<div class="menu">
<h1><a href="../">Etherpad</a></h1>
<ul>
<% e.begin_block("adminMenu"); %>
<li><a href="plugins" data-l10n-id="admin_plugins">Plugin manager</a></li>
<li><a href="settings" data-l10n-id="admin_settings">Settings</a></li>
<li><a href="plugins/info" data-l10n-id="admin_plugins_info">Troubleshooting information</a></li>
<% e.end_block(); %>
</ul>
</div>
<div class="innerwrapper">
<h2 data-l10n-id="admin_plugins.installed">Installed plugins</h2>
<table class="installed-results">
<thead>
<tr>
<th data-l10n-id="admin_plugins.name">Name</th>
<th data-l10n-id="admin_plugins.description">Description</th>
<th data-l10n-id="admin_plugins.version">Version</th>
<td></td>
</tr>
</thead>
<tbody class="template">
<tr id="installed-plugin-template">
<td class="name" data-label="Name"></td>
<td class="description" data-label="Description"></td>
<td class="version" data-label="Version"></td>
<td>
<div class="actions">
<input type="button" value="Uninstall" class="do-uninstall" data-l10n-id="admin_plugins.installed_uninstall.value">
<div class="progress"><p class="loadingAnimation"></p><p><span class="message"></span></p></div>
</div>
</td>
</tr>
</tbody>
<tbody id="installed-plugins">
</tbody>
<tbody class="messages">
<tr><td></td><td>
<p class="nothing-installed" data-l10n-id="admin_plugins.installed_nothing">You haven't installed any plugins yet.</p>
<p class="fetching"><p class="loadingAnimation"></p><br/><span data-l10n-id="admin_plugins.installed_fetching">Fetching installed plugins…</span></p>
</td><td></td></tr>
</tbody>
</table>
<div class="paged listing search-results">
<div class="separator"></div>
<h2 data-l10n-id="admin_plugins.available">Available plugins</h2>
<form>
<input type="text" name="search" disabled placeholder="Search for plugins to install" id="search-query" data-l10n-id="admin_plugins.available_search.placeholder">
</form>
<table>
<thead>
<tr>
<th class="sort up" data-label="name" data-l10n-id="admin_plugins.name">Name</th>
<th class="sort none" data-label="description" data-l10n-id="admin_plugins.description">Description</th>
<th class="sort none" data-label="version" data-l10n-id="admin_plugins.version">Version</th>
<th class="sort none" data-label="time" data-l10n-id="admin_plugins.last-update">Last update</th>
<td></td>
</tr>
</thead>
<tbody class="template">
<tr>
<td class="name" data-label="Name"></td>
<td class="description" data-label="Description"></td>
<td class="version" data-label="Version"></td>
<td class="time" data-label="Time"></td>
<td>
<div class="actions">
<input type="button" value="Install" class="do-install" data-l10n-id="admin_plugins.available_install.value">
<div class="progress"><p><p class="loadingAnimation"></p></p><p><span class="message"></span></p></div>
</div>
</td>
</tr>
</tbody>
<tbody class="results">
</tbody>
<tbody>
<tr><td></td><td>
<div class="messages">
<div id="search-progress" class="progress"><p>&nbsp;</p></div>
<p class="nothing-found" data-l10n-id="admin_plugins.available_not-found">No plugins found.</p>
<p class="fetching"><p class="loadingAnimation"></p><br/><span data-l10n-id="admin_plugins.available_fetching">Fetching…</span></p>
</div>
</td><td></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
</body>
</html>

View file

@ -1,58 +0,0 @@
<!doctype html>
<html>
<head>
<title data-l10n-id="admin_settings.page-title">Settings - Etherpad</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="../static/css/admin.css">
<script src="../static/js/vendors/jquery.js"></script>
<script src="../socket.io/socket.io.js"></script>
<script src="../static/js/socketio.js"></script>
<script src="../static/js/admin/minify.json.js"></script>
<script src="../static/js/admin/settings.js"></script>
<script src="../static/js/admin/jquery.autosize.js"></script>
<link rel="localizations" type="application/l10n+json" href="../locales.json" />
<script src="../static/js/vendors/html10n.js"></script>
<script src="../static/js/l10n.js"></script>
</head>
<body>
<div id="wrapper">
<% if (errors.length) { %>
<div class="errors">
<% errors.forEach(function (item) { %>
<div class="error"><%= item.toString() %></div>
<% }) %>
</div>
<% } %>
<div class="menu">
<h1><a href="../">Etherpad</a></h1>
<ul>
<% e.begin_block("adminMenu"); %>
<li><a href="plugins" data-l10n-id="admin_plugins">Plugin manager</a></li>
<li><a href="settings" data-l10n-id="admin_settings">Settings</a></li>
<li><a href="plugins/info" data-l10n-id="admin_plugins_info">Troubleshooting information</a></li>
<% e.end_block(); %>
</ul>
</div>
<div class="innerwrapper">
<h2 data-l10n-id="admin_settings.current">Current configuration</h2>
<textarea class="settings"></textarea>
<input type="button" class="settingsButton" id="saveSettings" value="Save Settings" data-l10n-id="admin_settings.current_save.value">
<input type="button" class="settingsButton" id="restartEtherpad" value="Restart Etherpad" data-l10n-id="admin_settings.current_restart.value">
<div id="response"></div>
<div class="separator"></div>
<a href='https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON' data-l10n-id="admin_settings.current_example-prod">Example production settings template</a>
<a href='https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON' data-l10n-id="admin_settings.current_example-devel">Example development settings template</a>
</div>
<div class="innerwrapper-err" >
<h2 class="err-message"></h2>
</div>
</div>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
</body>
</html>

View file

@ -34,24 +34,16 @@
<td><a href="/static/js/require-kernel.js">require-kernel.js</a></td> <td><a href="/static/js/require-kernel.js">require-kernel.js</a></td>
</tr> </tr>
<tr> <tr>
<td><a href="/static/js/admin/plugins.js">plugins.js</a></td>
<td><a href="http://www.apache.org/licenses/LICENSE-2.0">Apache-2.0-only</a></td> <td><a href="http://www.apache.org/licenses/LICENSE-2.0">Apache-2.0-only</a></td>
<td><a href="/static/js/admin/plugins.js">plugins.js</a></td>
</tr> </tr>
<tr> <tr>
<td><a href="/static/js/admin/minify.json.js">minify.json.js</a></td>
<td><a href="http://www.jclark.com/xml/copying.txt">Expat</a></td> <td><a href="http://www.jclark.com/xml/copying.txt">Expat</a></td>
<td><a href="/static/js/admin/minify.json.js">minify.json.js</a></td>
</tr> </tr>
<tr> <tr>
<td><a href="/static/js/admin/settings.js">settings.js</a></td>
<td><a href="http://www.apache.org/licenses/LICENSE-2.0">Apache-2.0-only</a></td> <td><a href="http://www.apache.org/licenses/LICENSE-2.0">Apache-2.0-only</a></td>
<td><a href="/static/js/admin/settings.js">settings.js</a></td>
</tr> </tr>
<tr> <tr>
<td><a href="/static/js/admin/jquery.autosize.js">jquery.autosize.js</a></td>
<td><a href="http://www.jclark.com/xml/copying.txt">Expat</a></td> <td><a href="http://www.jclark.com/xml/copying.txt">Expat</a></td>
<td><a href="/static/js/admin/jquery.autosize.js">jquery.autosize.js</a></td>
</tr> </tr>
</table> </table>
</body> </body>

View file

@ -54,10 +54,10 @@ describe(__filename, function () {
await agent.get('/').expect(200); await agent.get('/').expect(200);
}); });
it('!authn !authz anonymous /admin/ -> 401', async function () { it('!authn !authz anonymous /admin-auth// -> 401', async function () {
settings.requireAuthentication = false; settings.requireAuthentication = false;
settings.requireAuthorization = false; settings.requireAuthorization = false;
await agent.get('/admin/').expect(401); await agent.get('/admin-auth/').expect(401);
}); });
it('authn !authz anonymous / -> 401', async function () { it('authn !authz anonymous / -> 401', async function () {
@ -72,10 +72,10 @@ describe(__filename, function () {
await agent.get('/').auth('user', 'user-password').expect(200); await agent.get('/').auth('user', 'user-password').expect(200);
}); });
it('authn !authz user /admin/ -> 403', async function () { it('authn !authz user //admin-auth// -> 403', async function () {
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = false; settings.requireAuthorization = false;
await agent.get('/admin/').auth('user', 'user-password').expect(403); await agent.get('/admin-auth//').auth('user', 'user-password').expect(403);
}); });
it('authn !authz admin / -> 200', async function () { it('authn !authz admin / -> 200', async function () {
@ -84,10 +84,10 @@ describe(__filename, function () {
await agent.get('/').auth('admin', 'admin-password').expect(200); await agent.get('/').auth('admin', 'admin-password').expect(200);
}); });
it('authn !authz admin /admin/ -> 200', async function () { it('authn !authz admin /admin-auth/ -> 200', async function () {
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = false; settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200); await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);
}); });
it('authn authz anonymous /robots.txt -> 200', async function () { it('authn authz anonymous /robots.txt -> 200', async function () {
@ -102,10 +102,10 @@ describe(__filename, function () {
await agent.get('/').auth('user', 'user-password').expect(403); await agent.get('/').auth('user', 'user-password').expect(403);
}); });
it('authn authz user /admin/ -> 403', async function () { it('authn authz user //admin-auth// -> 403', async function () {
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = true; settings.requireAuthorization = true;
await agent.get('/admin/').auth('user', 'user-password').expect(403); await agent.get('/admin-auth//').auth('user', 'user-password').expect(403);
}); });
it('authn authz admin / -> 200', async function () { it('authn authz admin / -> 200', async function () {
@ -114,10 +114,10 @@ describe(__filename, function () {
await agent.get('/').auth('admin', 'admin-password').expect(200); await agent.get('/').auth('admin', 'admin-password').expect(200);
}); });
it('authn authz admin /admin/ -> 200', async function () { it('authn authz admin /admin-auth/ -> 200', async function () {
settings.requireAuthentication = true; settings.requireAuthentication = true;
settings.requireAuthorization = true; settings.requireAuthorization = true;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200); await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);
}); });
describe('login fails if password is nullish', function () { describe('login fails if password is nullish', function () {
@ -130,7 +130,7 @@ describe(__filename, function () {
it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { it(`admin password: ${adminPassword} credentials: ${creds}`, async function () {
settings.users.admin.password = adminPassword; settings.users.admin.password = adminPassword;
const encCreds = Buffer.from(creds).toString('base64'); const encCreds = Buffer.from(creds).toString('base64');
await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401); await agent.get('/admin-auth/').set('Authorization', `Basic ${encCreds}`).expect(401);
}); });
} }
} }
@ -228,11 +228,11 @@ describe(__filename, function () {
it('cannot grant access to /admin', async function () { it('cannot grant access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [true]; handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/admin/').expect(401); await agent.get('/admin-auth/').expect(401);
// Notes: // Notes:
// * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because
// 'true' entries are ignored for /admin/* requests. // 'true' entries are ignored for /admin-auth//* requests.
// * The authenticate hook always runs for /admin/* requests even if // * The authenticate hook always runs for /admin-auth//* requests even if
// settings.requireAuthentication is false. // settings.requireAuthentication is false.
assert.deepEqual(callOrder, ['preAuthorize_0', assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1', 'preAuthorize_1',
@ -240,9 +240,9 @@ describe(__filename, function () {
'authenticate_1']); 'authenticate_1']);
}); });
it('can deny access to /admin', async function () { it('can deny access to /admin-auth/', async function () {
handlers.preAuthorize[0].innerHandle = () => [false]; handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/admin/').auth('admin', 'admin-password').expect(403); await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']); assert.deepEqual(callOrder, ['preAuthorize_0']);
}); });
@ -258,7 +258,7 @@ describe(__filename, function () {
res.status(200).send('injected'); res.status(200).send('injected');
return cb([true]); return cb([true]);
})]; })];
await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); await agent.get('/admin-auth//').auth('admin', 'admin-password').expect(200, 'injected');
assert(called); assert(called);
}); });
@ -274,15 +274,15 @@ describe(__filename, function () {
settings.requireAuthorization = false; settings.requireAuthorization = false;
}); });
it('is not called if !requireAuthentication and not /admin/*', async function () { it('is not called if !requireAuthentication and not /admin-auth/*', async function () {
settings.requireAuthentication = false; settings.requireAuthentication = false;
await agent.get('/').expect(200); await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
}); });
it('is called if !requireAuthentication and /admin/*', async function () { it('is called if !requireAuthentication and /admin-auth//*', async function () {
settings.requireAuthentication = false; settings.requireAuthentication = false;
await agent.get('/admin/').expect(401); await agent.get('/admin-auth/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0', assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1', 'preAuthorize_1',
'authenticate_0', 'authenticate_0',
@ -393,7 +393,7 @@ describe(__filename, function () {
it('is not called if !requireAuthorization (/admin)', async function () { it('is not called if !requireAuthorization (/admin)', async function () {
settings.requireAuthorization = false; settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200); await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1', 'preAuthorize_1',
'authenticate_0', 'authenticate_0',