Simplify initial commit and remove unnecessary refactor

This commit is contained in:
realies 2017-09-22 23:25:14 +03:00
commit f1c40aa8de
64 changed files with 1580 additions and 1020 deletions

View File

@ -9,54 +9,54 @@ env:
node: true
rules:
arrow-body-style: 2
arrow-parens: [2, always]
arrow-spacing: 2
block-scoped-var: 2
block-spacing: [2, always]
brace-style: [2, 1tbs]
comma-dangle: 0
curly: [2, all]
dot-location: [2, property]
dot-notation: 2
eol-last: 2
eqeqeq: 2
handle-callback-err: 2
indent: [2, tab]
key-spacing: [2, {beforeColon: false, afterColon: true}]
keyword-spacing: [2, {before: true, after: true}]
linebreak-style: [2, unix]
no-catch-shadow: 2
no-confusing-arrow: 2
no-console: 0
no-control-regex: 0
no-duplicate-imports: 2
no-else-return: 2
no-implicit-globals: 2
no-multi-spaces: 2
no-multiple-empty-lines: [2, { "max": 1 }]
no-shadow: 2
no-template-curly-in-string: 2
no-trailing-spaces: 2
no-unsafe-negation: 2
no-useless-computed-key: 2
no-useless-return: 2
object-curly-spacing: [2, never]
padded-blocks: [2, never]
prefer-const: 2
quote-props: [2, as-needed]
quotes: [2, double, avoid-escape]
semi-spacing: 2
semi-style: [2, last]
semi: [2, always]
space-before-blocks: 2
space-before-function-paren: [2, never]
space-in-parens: [2, never]
space-infix-ops: 2
spaced-comment: [2, always]
strict: 2
template-curly-spacing: 2
yoda: 2
arrow-body-style: error
arrow-parens: [error, always]
arrow-spacing: error
block-scoped-var: error
block-spacing: [error, always]
brace-style: [error, 1tbs]
comma-dangle: off
curly: [error, all]
dot-location: [error, property]
dot-notation: error
eol-last: error
eqeqeq: error
handle-callback-err: error
indent: [error, tab]
key-spacing: [error, {beforeColon: false, afterColon: true}]
keyword-spacing: [error, {before: true, after: true}]
linebreak-style: [error, unix]
no-alert: error
no-catch-shadow: error
no-confusing-arrow: error
no-control-regex: off
no-duplicate-imports: error
no-else-return: error
no-implicit-globals: error
no-multi-spaces: error
no-multiple-empty-lines: [error, { "max": 1 }]
no-shadow: error
no-template-curly-in-string: error
no-trailing-spaces: error
no-unsafe-negation: error
no-useless-computed-key: error
no-useless-return: error
object-curly-spacing: [error, never]
padded-blocks: [error, never]
prefer-const: error
quote-props: [error, as-needed]
quotes: [error, double, avoid-escape]
semi-spacing: error
semi-style: [error, last]
semi: [error, always]
space-before-blocks: error
space-before-function-paren: [error, never]
space-in-parens: [error, never]
space-infix-ops: error
spaced-comment: [error, always]
strict: error
template-curly-spacing: error
yoda: error
globals:
log: false

View File

@ -1,93 +0,0 @@
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,93 +0,0 @@
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,25 +1,3 @@
@font-face {
font-family: "Lato";
font-weight: 400;
font-style: normal;
src:
local("Lato Regular"),
local("Lato-regular"),
url("fonts/Lato-regular/Lato-regular.woff2") format("woff2"),
url("fonts/Lato-regular/Lato-regular.woff") format("woff");
}
@font-face {
font-family: "Lato";
font-weight: 700;
font-style: normal;
src:
local("Lato Bold"),
local("Lato-700"),
url("fonts/Lato-700/Lato-700.woff2") format("woff2"),
url("fonts/Lato-700/Lato-700.woff") format("woff");
}
@font-face {
font-family: "FontAwesome";
font-weight: normal;
@ -37,13 +15,14 @@ body {
body {
background: #455164;
color: #222;
font: 16px Lato, sans-serif;
font: 16px -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: default;
touch-action: none;
/**
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
@ -159,6 +138,7 @@ kbd {
.container {
margin: 80px auto;
max-width: 480px;
touch-action: pan-y;
}
::-moz-placeholder {
@ -174,6 +154,7 @@ kbd {
color: rgba(0, 0, 0, 0.35) !important;
}
#js-copy-hack,
#help,
#windows .header .title,
#windows .header .topic,
@ -185,6 +166,16 @@ kbd {
cursor: text;
}
#js-copy-hack {
position: absolute;
left: -999999px;
}
#chat #js-copy-hack .condensed:not(.closed) .msg,
#chat #js-copy-hack > .msg {
display: block;
}
/* Icons */
#viewport .lt::before,
@ -197,6 +188,8 @@ kbd {
#settings .extra-help,
#settings #play::before,
#form #submit::before,
#chat .away .from::before,
#chat .back .from::before,
#chat .invite .from::before,
#chat .join .from::before,
#chat .kick .from::before,
@ -247,6 +240,12 @@ kbd {
#form #submit::before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ }
#chat .away .from::before,
#chat .back .from::before {
content: "\f017"; /* http://fontawesome.io/icon/clock-o/ */
color: #7f8c8d;
}
#chat .invite .from::before {
content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */
color: #2ecc40;
@ -473,6 +472,7 @@ kbd {
#sidebar .networks {
padding: 20px 30px 0;
touch-action: pan-y;
}
#sidebar .networks:empty {
@ -482,6 +482,7 @@ kbd {
#sidebar .network,
#sidebar .network-placeholder {
margin-bottom: 30px;
touch-action: pan-y;
}
#sidebar .empty {
@ -834,7 +835,7 @@ kbd {
display: flex;
}
#chat .condensed-summary:hover {
#chat .condensed-summary .content:hover {
opacity: 0.6;
}
@ -850,13 +851,18 @@ kbd {
visibility: hidden;
}
#windows #form .input,
#windows .header .topic,
.messages .msg,
.sidebar {
font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
font-size: 14px;
line-height: 1.4;
}
#windows #form .input {
font-size: 13px;
}
#windows #chat .header {
display: block;
}
@ -896,6 +902,7 @@ kbd {
right: 0;
width: 180px;
transition: right 0.4s;
touch-action: pan-y;
}
#chat .show-more {
@ -924,6 +931,7 @@ kbd {
#chat .messages {
padding: 10px 0;
touch-action: pan-y;
}
#chat .msg {
@ -1001,7 +1009,6 @@ kbd {
#chat .time,
#chat .from,
#chat .content {
display: block;
padding: 2px 0;
flex: 0 0 auto;
}
@ -1147,6 +1154,8 @@ kbd {
}
#chat .condensed .content,
#chat .away .content,
#chat .back .content,
#chat .join .content,
#chat .kick .content,
#chat .mode .content,
@ -1286,6 +1295,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
position: absolute;
top: 48px;
width: 100%;
touch-action: pan-y;
}
#chat .names-filtered {
@ -1463,6 +1473,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
line-height: 1.8;
}
.is-apple #help .key-all,
#help .key-apple {
display: none;
}
.is-apple #help .key-apple {
display: inline-block;
}
#form {
background: #eee;
border-top: 1px solid #ddd;
@ -1472,7 +1491,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
#windows #form .input {
font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
border: 1px solid #ddd;
border-radius: 2px;
margin: 0;
@ -1485,6 +1503,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
#connection-error {
font-size: 12px;
line-height: 36px;
font-weight: bold;
letter-spacing: 1px;
word-spacing: 3px;
text-transform: uppercase;
background: #e74c3c;
color: #fff;
text-align: center;
display: none;
}
@ -1502,7 +1529,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
background: #f6f6f6;
color: #666;
font: inherit;
font-size: 11px;
font-size: 13px;
margin: 4px;
line-height: 22px;
height: 24px;
@ -1561,6 +1588,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
-webkit-flex: 1 0 auto;
flex: 1 0 auto;
align-self: center;
touch-action: pan-y;
}
#form #submit {
@ -1671,7 +1699,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
z-index: 1000000;
display: none;
padding: 5px 8px;
font: 12px Lato;
font-size: 12px;
line-height: 1.2;
-webkit-font-smoothing: subpixel-antialiased;
color: #fff;
@ -1976,6 +2004,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-top: 60px !important;
}
.messages .msg {
font-size: 16px;
}
#sidebar,
#footer {
left: -220px;
@ -2019,14 +2051,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
}
@media (min-width: 1610px) {
#windows .header .topic,
.messages .msg,
.sidebar {
font-size: 14px;
}
}
@media (max-width: 479px) {
.container {
margin: 40px 0 !important;
@ -2180,6 +2204,13 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
/* Top/Bottom margins + button height + image/button margin */
max-height: calc(100vh - 2 * 10px - 37px - 10px);
/* Checkered background for transparent images */
background-position: 0 0, 10px 10px;
background-size: 20px 20px;
background-image:
linear-gradient(45deg, #eee 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 75%, #eee 75%, #eee 100%),
linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);
}
#image-viewer .open-btn {

View File

@ -63,7 +63,7 @@
</div>
<div id="chat-container" class="window">
<div id="chat"></div>
<button id="connection-error" class="btn btn-reconnect">Client connection lost &mdash; Click here to reconnect</button>
<div id="connection-error"></div>
<form id="form" method="post" action="">
<div class="input">
<span id="nick">
@ -225,8 +225,8 @@
<div class="col-sm-12">
<h2>
Status messages
<span class="tooltipped tooltipped-n tooltipped-no-delay" aria-label="Joins, parts, kicks, nick changes, and mode changes">
<button class="extra-help" aria-label="Joins, parts, kicks, nick changes, and mode changes"></button>
<span class="tooltipped tooltipped-n tooltipped-no-delay" aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes">
<button class="extra-help" aria-label="Joins, parts, kicks, nick changes, away changes, and mode changes"></button>
</span>
</h2>
</div>
@ -264,8 +264,8 @@
<label for="theme-select" class="sr-only">Theme</label>
<select id="theme-select" name="theme" class="input">
{{#each themes}}
<option value="{{filename}}">
{{name}}
<option value="{{name}}">
{{displayName}}
</option>
{{/each}}
</select>
@ -392,11 +392,9 @@
<h2>Keyboard Shortcuts</h2>
<h3>On Windows / Linux</h3>
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd></kbd> / <kbd></kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd></kbd> / <kbd></kbd>
</div>
<div class="description">
<p>Switch to the previous/next window in the channel list</p>
@ -405,16 +403,7 @@
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>L</kbd>
</div>
<div class="description">
<p>Clear the current screen</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>K</kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd>K</kbd>
</div>
<div class="description">
<p>
@ -431,7 +420,7 @@
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>B</kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd>B</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as bold.</p>
@ -440,7 +429,7 @@
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>U</kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd>U</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as underlined.</p>
@ -449,7 +438,7 @@
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>I</kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd>I</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as italics.</p>
@ -458,83 +447,7 @@
<div class="help-item">
<div class="subject">
<kbd>Ctrl</kbd> + <kbd>O</kbd>
</div>
<div class="description">
<p>
Mark all text typed after this shortcut to be reset to its
original formatting.
</p>
</div>
</div>
<h3>On macOS</h3>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd></kbd> / <kbd></kbd>
</div>
<div class="description">
<p>Switch to the previous/next window in the channel list</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd></kbd> + <kbd>L</kbd>
</div>
<div class="description">
<p>Clear the current screen</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd>K</kbd>
</div>
<div class="description">
<p>
Mark any text typed after this shortcut to be colored. After
hitting this shortcut, enter an integer in the
<code>0—15</code> range to select the desired color.
</p>
<p>
A color reference can be found
<a href="https://modern.ircdocs.horse/formatting.html#colors" target="_blank" rel="noopener">here</a>.
</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd>B</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as bold.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd>U</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as underlined.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd>I</kbd>
</div>
<div class="description">
<p>Mark all text typed after this shortcut as italics.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<kbd></kbd> + <kbd>O</kbd>
<kbd class="key-all">Ctrl</kbd><kbd class="key-apple"></kbd> + <kbd>O</kbd>
</div>
<div class="description">
<p>
@ -583,15 +496,6 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/clear</code>
</div>
<div class="description">
<p>Clear the current screen.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/collapse</code>
@ -787,10 +691,13 @@
<div class="help-item">
<div class="subject">
<code>/part</code>
<code>/part [channel]</code>
</div>
<div class="description">
<p>Close the current channel or private message window.</p>
<p>
Close the specified channel or private message window, or the
current channel if <code>channel</code> is ommitted.
</p>
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
</div>
</div>

38
client/js/clipboard.js Normal file
View File

@ -0,0 +1,38 @@
"use strict";
const $ = require("jquery");
const chat = document.getElementById("chat");
function copyMessages() {
const selection = window.getSelection();
// If selection does not span multiple elements, do nothing
if (selection.anchorNode === selection.focusNode) {
return;
}
const range = selection.getRangeAt(0);
const documentFragment = range.cloneContents();
const div = document.createElement("div");
$(documentFragment)
.find(".from .user")
.each((_, el) => {
el = $(el);
el.text(`<${el.text()}>`);
});
div.id = "js-copy-hack";
div.appendChild(documentFragment);
chat.appendChild(div);
selection.selectAllChildren(div);
window.setTimeout(() => {
chat.removeChild(div);
selection.removeAllRanges();
selection.addRange(range);
}, 0);
}
$(chat).on("copy", ".messages", copyMessages);

View File

@ -23,6 +23,12 @@ function updateText(condensed, addedTypes) {
constants.condensedTypes.forEach((type) => {
if (obj[type]) {
switch (type) {
case "away":
strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away"));
break;
case "back":
strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back"));
break;
case "join":
strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel"));
break;

View File

@ -37,6 +37,7 @@ const commands = [
"/join",
"/kick",
"/leave",
"/list",
"/me",
"/mode",
"/msg",
@ -59,6 +60,8 @@ const commands = [
];
const actionTypes = [
"away",
"back",
"ban_list",
"invite",
"join",
@ -76,6 +79,8 @@ const actionTypes = [
];
const condensedTypes = [
"away",
"back",
"join",
"part",
"quit",

101
client/js/keybinds.js Normal file
View File

@ -0,0 +1,101 @@
"use strict";
const $ = require("jquery");
const Mousetrap = require("mousetrap");
const input = $("#input");
const sidebar = $("#sidebar");
const windows = $("#windows");
const contextMenuContainer = $("#context-menu-container");
Mousetrap.bind([
"pageup",
"pagedown"
], function(e, key) {
let container = windows.find(".window.active");
// Chat windows scroll message container
if (container.attr("id") === "chat-container") {
container = container.find(".chan.active .chat");
}
container.finish();
const offset = container.get(0).clientHeight * 0.9;
let scrollTop = container.scrollTop();
if (key === "pageup") {
scrollTop = Math.floor(scrollTop - offset);
} else {
scrollTop = Math.ceil(scrollTop + offset);
}
container.animate({
scrollTop: scrollTop
}, 200);
return false;
});
Mousetrap.bind([
"command+up",
"command+down",
"ctrl+up",
"ctrl+down"
], function(e, keys) {
const channels = sidebar.find(".chan");
const index = channels.index(channels.filter(".active"));
const direction = keys.split("+").pop();
let target;
switch (direction) {
case "up":
target = (channels.length + (index - 1 + channels.length)) % channels.length;
break;
case "down":
target = (channels.length + (index + 1 + channels.length)) % channels.length;
break;
}
channels.eq(target).click();
});
Mousetrap.bind([
"escape"
], function() {
contextMenuContainer.hide();
});
const colorsHotkeys = {
k: "\x03",
b: "\x02",
u: "\x1F",
i: "\x1D",
o: "\x0F",
};
for (const hotkey in colorsHotkeys) {
Mousetrap.bind([
"command+" + hotkey,
"ctrl+" + hotkey
], function(e) {
e.preventDefault();
const cursorPosStart = input.prop("selectionStart");
const cursorPosEnd = input.prop("selectionEnd");
const value = input.val();
let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key];
if (cursorPosStart === cursorPosEnd) {
// If no text is selected, insert at cursor
newValue += value.substring(cursorPosEnd, value.length);
} else {
// If text is selected, insert formatting character at start and the end
newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length);
}
input
.val(newValue)
.get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1);
});
}

View File

@ -4,24 +4,23 @@
require("jquery-ui/ui/widgets/sortable");
const $ = require("jquery");
const moment = require("moment");
const Mousetrap = require("mousetrap");
const URI = require("urijs");
const fuzzy = require("fuzzy");
// our libraries
require("./libs/jquery/inputhistory");
require("./libs/jquery/stickyscroll");
const helpers_roundBadgeNumber = require("./libs/handlebars/roundBadgeNumber");
const slideoutMenu = require("./libs/slideout");
const templates = require("../views");
const socket = require("./socket");
require("./socket-events");
const storage = require("./localStorage");
const options = require("./options");
require("./options");
const utils = require("./utils");
const modules = require("./modules");
require("./autocompletion");
require("./webpush");
require("./keybinds");
require("./clipboard");
$(function() {
var sidebar = $("#sidebar, #footer");
@ -29,20 +28,6 @@ $(function() {
$(document.body).data("app-name", document.title);
var pop;
try {
pop = new Audio();
pop.src = "audio/pop.ogg";
} catch (e) {
pop = {
play: $.noop
};
}
$("#play").on("click", function() {
pop.play();
});
var windows = $("#windows");
var viewport = $("#viewport");
var sidebarSlide = slideoutMenu(viewport[0], sidebar[0]);
@ -183,6 +168,10 @@ $(function() {
});
}
if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) {
$(document.body).addClass("is-apple");
}
$("#form").on("submit", function(e) {
e.preventDefault();
utils.forceFocus();
@ -199,22 +188,19 @@ $(function() {
const separatorPos = text.indexOf(" ");
const cmd = text.substring(1, separatorPos > 1 ? separatorPos : text.length);
const parameters = separatorPos > text.indexOf(cmd) ? text.substring(text.indexOf(cmd) + cmd.length + 1, text.length) : "";
switch (cmd) {
case "clear":
if (modules.clear()) return;
break;
case "collapse":
if (modules.collapse()) return;
break;
case "expand":
if (modules.expand()) return;
break;
case "join":
if (typeof utils[cmd] === "function") {
if (cmd === "join") {
const channel = parameters.split(" ")[0];
if (channel != "") {
if (modules.join(channel)) return;
if (channel !== "") {
if (utils[cmd](channel)) {
return;
}
}
break;
} else {
if (utils[cmd]()) {
return;
}
}
}
}
@ -327,16 +313,16 @@ $(function() {
const state = {};
if (self.hasClass("chan")) {
state.clickTarget = `.chan[data-id="${self.data("id")}"]`;
state.clickTarget = `#sidebar .chan[data-id="${self.data("id")}"]`;
} else {
state.clickTarget = `#footer button[data-target="${target}"]`;
}
if (history && history.pushState) {
if (data && data.replaceHistory && history.replaceState) {
history.replaceState(state, null, null);
history.replaceState(state, null, target);
} else {
history.pushState(state, null, null);
history.pushState(state, null, target);
}
}
});
@ -382,6 +368,7 @@ $(function() {
lastActiveChan
.find(".unread-marker")
.data("unread-id", 0)
.appendTo(lastActiveChan.find(".messages"));
var chan = $(target)
@ -433,7 +420,7 @@ $(function() {
if (chan.hasClass("lobby")) {
cmd = "/quit";
var server = chan.find(".name").html();
if (!confirm("Disconnect from " + server + "?")) {
if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert
return false;
}
}
@ -490,95 +477,6 @@ $(function() {
container.html(templates.user_filtered({matches: result})).show();
});
chat.on("msg", ".messages", function(e, target, msg) {
var unread = msg.unread;
msg = msg.msg;
if (msg.self) {
return;
}
var button = sidebar.find(".chan[data-target='" + target + "']");
if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) {
if (!document.hasFocus() || !$(target).hasClass("active")) {
if (options.notification) {
try {
pop.play();
} catch (exception) {
// On mobile, sounds can not be played without user interaction.
}
}
utils.toggleNotificationMarkers(true);
if (options.desktopNotifications && Notification.permission === "granted") {
var title;
var body;
if (msg.type === "invite") {
title = "New channel invite:";
body = msg.from + " invited you to " + msg.channel;
} else {
title = msg.from;
if (!button.hasClass("query")) {
title += " (" + button.data("title").trim() + ")";
}
if (msg.type === "message") {
title += " says:";
}
body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim();
}
try {
var notify = new Notification(title, {
body: body,
icon: "img/logo-64.png",
tag: target
});
notify.addEventListener("click", function() {
window.focus();
button.click();
this.close();
});
} catch (exception) {
// `new Notification(...)` is not supported and should be silenced.
}
}
}
}
if (button.hasClass("active")) {
return;
}
if (!unread) {
return;
}
var badge = button.find(".badge").html(helpers_roundBadgeNumber(unread));
if (msg.highlight) {
badge.addClass("highlight");
}
});
chat.on("click", ".show-more-button", function() {
var self = $(this);
var lastMessage = self.parent().next(".messages").children(".msg").first();
if (lastMessage.is(".condensed")) {
lastMessage = lastMessage.children(".msg").first();
}
var lastMessageId = parseInt(lastMessage[0].id.replace("msg-", ""), 10);
self
.text("Loading older messages…")
.prop("disabled", true);
socket.emit("more", {
target: self.data("id"),
lastId: lastMessageId
});
});
var forms = $("#sign-in, #connect, #change-password");
windows.on("show", "#sign-in", function() {
@ -590,7 +488,8 @@ $(function() {
}
});
});
if ($("body").hasClass("public")) {
if ($("body").hasClass("public") && window.location.hash === "#connect") {
$("#connect").one("show", function() {
var params = URI(document.location.search);
params = params.search(true);
@ -620,23 +519,25 @@ $(function() {
e.preventDefault();
var event = "auth";
var form = $(this);
form.find(".btn")
.attr("disabled", true)
.end();
form.find(".btn").attr("disabled", true);
if (form.closest(".window").attr("id") === "connect") {
event = "conn";
} else if (form.closest("div").attr("id") === "change-password") {
event = "change-password";
}
var values = {};
$.each(form.serializeArray(), function(i, obj) {
if (obj.value !== "") {
values[obj.name] = obj.value;
}
});
if (values.user) {
storage.set("user", values.user);
}
socket.emit(
event, values
);
@ -664,111 +565,6 @@ $(function() {
$(this).data("lastvalue", nick);
});
(function HotkeysScope() {
Mousetrap.bind([
"pageup",
"pagedown"
], function(e, key) {
let container = windows.find(".window.active");
// Chat windows scroll message container
if (container.attr("id") === "chat-container") {
container = container.find(".chan.active .chat");
}
container.finish();
const offset = container.get(0).clientHeight * 0.9;
let scrollTop = container.scrollTop();
if (key === "pageup") {
scrollTop = Math.floor(scrollTop - offset);
} else {
scrollTop = Math.ceil(scrollTop + offset);
}
container.animate({
scrollTop: scrollTop
}, 200);
return false;
});
Mousetrap.bind([
"command+up",
"command+down",
"ctrl+up",
"ctrl+down"
], function(e, keys) {
var channels = sidebar.find(".chan");
var index = channels.index(channels.filter(".active"));
var direction = keys.split("+").pop();
switch (direction) {
case "up":
// Loop
var upTarget = (channels.length + (index - 1 + channels.length)) % channels.length;
channels.eq(upTarget).click();
break;
case "down":
// Loop
var downTarget = (channels.length + (index + 1 + channels.length)) % channels.length;
channels.eq(downTarget).click();
break;
}
});
Mousetrap.bind([
"command+shift+l",
"ctrl+shift+l"
], function(e) {
if (e.target === input[0]) {
utils.clear();
e.preventDefault();
}
});
Mousetrap.bind([
"escape"
], function() {
contextMenuContainer.hide();
});
var colorsHotkeys = {
k: "\x03",
b: "\x02",
u: "\x1F",
i: "\x1D",
o: "\x0F",
};
for (var hotkey in colorsHotkeys) {
Mousetrap.bind([
"command+" + hotkey,
"ctrl+" + hotkey
], function(e) {
e.preventDefault();
const cursorPosStart = input.prop("selectionStart");
const cursorPosEnd = input.prop("selectionEnd");
const value = input.val();
let newValue = value.substring(0, cursorPosStart) + colorsHotkeys[e.key];
if (cursorPosStart === cursorPosEnd) {
// If no text is selected, insert at cursor
newValue += value.substring(cursorPosEnd, value.length);
} else {
// If text is selected, insert formatting character at start and the end
newValue += value.substring(cursorPosStart, cursorPosEnd) + colorsHotkeys[e.key] + value.substring(cursorPosEnd, value.length);
}
input
.val(newValue)
.get(0).setSelectionRange(cursorPosStart + 1, cursorPosEnd + 1);
});
}
}());
$(document).on("visibilitychange focus click", () => {
if (sidebar.find(".highlight").length === 0) {
utils.toggleNotificationMarkers(false);
@ -786,7 +582,7 @@ $(function() {
$(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']")
.closest(".date-marker-container")
.each(function() {
$(this).replaceWith(templates.date_marker({msgDate: $(this).data("timestamp")}));
$(this).replaceWith(templates.date_marker({time: $(this).data("time")}));
});
// This should always be 24h later but re-computing exact value just in case
@ -794,20 +590,33 @@ $(function() {
}
setTimeout(updateDateMarkers, msUntilNextDay());
// Only start opening socket.io connection after all events have been registered
socket.open();
window.addEventListener("popstate", (e) => {
const {state} = e;
if (!state) {
return;
}
const {clickTarget} = state;
let {clickTarget} = state;
if (clickTarget) {
// This will be true when click target corresponds to opening a thumbnail,
// browsing to the previous/next thumbnail, or closing the image viewer.
const imageViewerRelated = clickTarget.includes(".toggle-thumbnail");
// If the click target is not related to the image viewer but the viewer
// is currently opened, we need to close it.
if (!imageViewerRelated && $("#image-viewer").hasClass("opened")) {
clickTarget += ", #image-viewer";
}
// Emit the click to the target, while making sure it is not going to be
// added to the state again.
$(clickTarget).trigger("click", {
pushState: false
});
}
});
// Only start opening socket.io connection after all events have been registered
socket.open();
});

View File

@ -1,35 +0,0 @@
"use strict";
// vendor libraries
const $ = require("jquery");
// our libraries
const utils = require("./utils");
module.exports = {
clear,
collapse,
expand,
join
};
function clear() {
utils.clear();
}
function collapse() {
$(".chan.active .toggle-button.opened").click();
}
function expand() {
$(".chan.active .toggle-button:not(.opened)").click();
}
function join(channel) {
var chan = utils.findCurrentNetworkChan(channel);
if (chan.length) {
chan.click();
return true;
}
}

View File

@ -8,6 +8,7 @@ const utils = require("./utils");
const sorting = require("./sorting");
const constants = require("./constants");
const condensed = require("./condensed");
const helpers_parse = require("./libs/handlebars/parse");
const chat = $("#chat");
const sidebar = $("#sidebar");
@ -27,45 +28,29 @@ module.exports = {
renderNetworks,
};
function buildChannelMessages(chanId, chanType, messages) {
function buildChannelMessages(container, chanId, chanType, messages) {
return messages.reduce((docFragment, message) => {
appendMessage(docFragment, chanId, chanType, message);
return docFragment;
}, $(document.createDocumentFragment()));
}, container);
}
function appendMessage(container, chanId, chanType, msg) {
const renderedMessage = buildChatMessage(chanId, msg);
if (utils.lastMessageId < msg.id) {
utils.lastMessageId = msg.id;
}
let lastChild = container.children(".msg, .date-marker-container").last();
const renderedMessage = buildChatMessage(msg);
// Check if date changed
let lastChild = container.find(".msg").last();
const msgTime = new Date(msg.time);
// It's the first message in a window,
// then just append the message and do nothing else
if (lastChild.length === 0) {
container
.append(templates.date_marker({msgDate: msgTime}))
.append(renderedMessage);
return;
}
const prevMsgTime = new Date(lastChild.attr("data-time"));
const parent = lastChild.parent();
// If this message is condensed, we have to work on the wrapper
if (parent.hasClass("condensed")) {
lastChild = parent;
}
const prevMsgTime = new Date(lastChild.data("time"));
// Insert date marker if date changed compared to previous message
if (prevMsgTime.toDateString() !== msgTime.toDateString()) {
lastChild.after(templates.date_marker({msgDate: msgTime}));
// If date changed, we don't need to do condensed logic
container.append(renderedMessage);
return;
lastChild = $(templates.date_marker({time: msg.time}));
container.append(lastChild);
}
// If current window is not a channel or this message is not condensable,
@ -83,25 +68,16 @@ function appendMessage(container, chanId, chanType, msg) {
return;
}
const newCondensed = buildChatMessage(chanId, {
type: "condensed",
time: msg.time,
previews: []
});
// Always create a condensed container
const newCondensed = $(templates.msg_condensed({time: msg.time}));
condensed.updateText(newCondensed, [msg.type]);
newCondensed.append(renderedMessage);
container.append(newCondensed);
}
function buildChatMessage(chanId, msg) {
function buildChatMessage(msg) {
const type = msg.type;
let target = "#chan-" + chanId;
if (type === "error") {
target = "#chan-" + chat.find(".active").data("id");
}
const chan = chat.find(target);
let template = "msg";
// See if any of the custom highlight regexes match
@ -117,8 +93,6 @@ function buildChatMessage(chanId, msg) {
template = "msg_action";
} else if (type === "unhandled") {
template = "msg_unhandled";
} else if (type === "condensed") {
template = "msg_condensed";
}
const renderedMessage = $(templates[template](msg));
@ -132,17 +106,6 @@ function buildChatMessage(chanId, msg) {
renderPreview(preview, renderedMessage);
});
if ((type === "message" || type === "action" || type === "notice") && chan.hasClass("channel")) {
const nicks = chan.find(".users").data("nicks");
if (nicks) {
const find = nicks.indexOf(msg.from);
if (find !== -1) {
nicks.splice(find, 1);
nicks.unshift(msg.from);
}
}
}
return renderedMessage;
}
@ -159,22 +122,28 @@ function renderChannel(data) {
}
function renderChannelMessages(data) {
const documentFragment = buildChannelMessages(data.id, data.type, data.messages);
const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages);
const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment);
if (data.firstUnread > 0) {
const first = channel.find("#msg-" + data.firstUnread);
const template = $(templates.unread_marker());
if (data.firstUnread > 0) {
let first = channel.find("#msg-" + data.firstUnread);
// TODO: If the message is far off in the history, we still need to append the marker into DOM
if (!first.length) {
channel.prepend(templates.unread_marker());
} else if (first.parent().hasClass("condensed")) {
first.parent().before(templates.unread_marker());
template.data("unread-id", data.firstUnread);
channel.prepend(template);
} else {
first.before(templates.unread_marker());
const parent = first.parent();
if (parent.hasClass("condensed")) {
first = parent;
}
first.before(template);
}
} else {
channel.append(templates.unread_marker());
channel.append(template);
}
}
@ -200,7 +169,7 @@ function renderChannelUsers(data) {
}
}
function renderNetworks(data) {
function renderNetworks(data, singleNetwork) {
sidebar.find(".empty").hide();
sidebar.find(".networks").append(
templates.network({
@ -208,15 +177,51 @@ function renderNetworks(data) {
})
);
let newChannels;
const channels = $.map(data.networks, function(n) {
return n.channels;
});
if (!singleNetwork && utils.lastMessageId > -1) {
newChannels = [];
channels.forEach((channel) => {
const chan = $("#chan-" + channel.id);
if (chan.length > 0) {
if (chan.data("type") === "channel") {
chan
.data("needsNamesRefresh", true)
.find(".header .topic")
.html(helpers_parse(channel.topic))
.attr("title", channel.topic);
}
if (channel.messages.length > 0) {
const container = chan.find(".messages");
buildChannelMessages(container, channel.id, channel.type, channel.messages);
if (container.find(".msg").length >= 100) {
container.find(".show-more").addClass("show");
}
container.trigger("keepToBottom");
}
} else {
newChannels.push(channel);
}
});
} else {
newChannels = channels;
}
chat.append(
templates.chat({
channels: channels
})
);
channels.forEach((channel) => {
newChannels.forEach((channel) => {
renderChannel(channel);
if (channel.type === "channel") {

View File

@ -92,10 +92,12 @@ function handleImageInPreview(content, container) {
const imageViewer = $("#image-viewer");
$("#chat").on("click", ".toggle-thumbnail", function() {
$("#chat").on("click", ".toggle-thumbnail", function(event, data = {}) {
const link = $(this);
openImageViewer(link);
// Passing `data`, specifically `data.pushState`, to not add the action to the
// history state if back or forward buttons were pressed.
openImageViewer(link, data);
// Prevent the link to open a new page since we're opening the image viewer,
// but keep it a link to allow for Ctrl/Cmd+click.
@ -103,8 +105,10 @@ $("#chat").on("click", ".toggle-thumbnail", function() {
return false;
});
imageViewer.on("click", function() {
closeImageViewer();
imageViewer.on("click", function(event, data = {}) {
// Passing `data`, specifically `data.pushState`, to not add the action to the
// history state if back or forward buttons were pressed.
closeImageViewer(data);
});
$(document).keydown(function(e) {
@ -125,7 +129,7 @@ $(document).keydown(function(e) {
}
});
function openImageViewer(link) {
function openImageViewer(link, {pushState = true} = {}) {
$(".previous-image").removeClass("previous-image");
$(".next-image").removeClass("next-image");
@ -161,7 +165,20 @@ function openImageViewer(link) {
hasNextImage: nextImage.length > 0,
}));
imageViewer.addClass("opened");
// Turn off transitionend listener before opening the viewer,
// which caused image viewer to become empty in rare cases
imageViewer
.off("transitionend")
.addClass("opened");
// History management
if (pushState) {
const clickTarget =
`#${link.closest(".msg").attr("id")} ` +
`a.toggle-thumbnail[href="${link.attr("href")}"] ` +
"img";
history.pushState({clickTarget}, null, null);
}
}
imageViewer.on("click", ".previous-image-btn", function() {
@ -174,7 +191,7 @@ imageViewer.on("click", ".next-image-btn", function() {
return false;
});
function closeImageViewer() {
function closeImageViewer({pushState = true} = {}) {
imageViewer
.removeClass("opened")
.one("transitionend", function() {
@ -182,4 +199,12 @@ function closeImageViewer() {
});
input.focus();
// History management
if (pushState) {
const clickTarget =
"#sidebar " +
`.chan[data-id="${$("#sidebar .chan.active").data("id")}"]`;
history.pushState({clickTarget}, null, null);
}
}

View File

@ -3,8 +3,20 @@
const $ = require("jquery");
const socket = require("../socket");
const storage = require("../localStorage");
const utils = require("../utils");
socket.on("auth", function(data) {
// If we reconnected and serverHash differs, that means the server restarted
// And we will reload the page to grab the latest version
if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) {
socket.disconnect();
$("#connection-error").text("Server restarted, reloading…");
location.reload(true);
return;
}
utils.serverHash = data.serverHash;
const login = $("#sign-in");
let token;
const user = storage.get("user");
@ -12,6 +24,13 @@ socket.on("auth", function(data) {
login.find(".btn").prop("disabled", false);
if (!data.success) {
if (login.length === 0) {
socket.disconnect();
$("#connection-error").text("Authentication failed, reloading…");
location.reload();
return;
}
storage.remove("token");
const error = login.find(".error");
@ -20,9 +39,15 @@ socket.on("auth", function(data) {
});
} else if (user) {
token = storage.get("token");
if (token) {
$("#loading-page-message").text("Authorizing…");
socket.emit("auth", {user: user, token: token});
$("#loading-page-message, #connection-error").text("Authorizing…");
socket.emit("auth", {
user: user,
token: token,
lastMessage: utils.lastMessageId,
});
}
}
@ -34,13 +59,9 @@ socket.on("auth", function(data) {
return;
}
$("#footer").find(".sign-in")
$("#footer")
.find(".sign-in")
.trigger("click", {
pushState: false,
})
.end()
.find(".networks")
.html("")
.next()
.show();
});
});

View File

@ -1,16 +1,28 @@
"use strict";
const $ = require("jquery");
const escape = require("css.escape");
const socket = require("../socket");
const render = require("../render");
const webpush = require("../webpush");
const sidebar = $("#sidebar");
const storage = require("../localStorage");
const utils = require("../utils");
socket.on("init", function(data) {
$("#loading-page-message").text("Rendering…");
$("#loading-page-message, #connection-error").text("Rendering…");
const lastMessageId = utils.lastMessageId;
let previousActive = 0;
if (lastMessageId > -1) {
previousActive = sidebar.find(".active").data("id");
sidebar.find(".networks").empty();
}
if (data.networks.length === 0) {
sidebar.find(".empty").show();
$("#footer").find(".connect").trigger("click", {
pushState: false,
});
@ -18,28 +30,59 @@ socket.on("init", function(data) {
render.renderNetworks(data);
}
if (data.token) {
storage.set("token", data.token);
}
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
$("body").removeClass("signed-out");
$("#loading").remove();
$("#sign-in").remove();
const id = data.active;
const target = sidebar.find("[data-id='" + id + "']").trigger("click", {
replaceHistory: true
});
if (target.length === 0) {
const first = sidebar.find(".chan")
.eq(0)
.trigger("click");
if (first.length === 0) {
$("#footer").find(".connect").trigger("click", {
pushState: false,
});
if (lastMessageId > -1) {
$("#connection-error").removeClass("shown");
$(".show-more-button, #input").prop("disabled", false);
$("#submit").show();
} else {
if (data.token) {
storage.set("token", data.token);
}
webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey);
$("body").removeClass("signed-out");
$("#loading").remove();
$("#sign-in").remove();
}
openCorrectChannel(previousActive, data.active);
});
function openCorrectChannel(clientActive, serverActive) {
let target = $();
// Open last active channel
if (clientActive > 0) {
target = sidebar.find("[data-id='" + clientActive + "']");
}
// Open window provided in location.hash
if (target.length === 0 && window.location.hash) {
target = $("#footer, #sidebar").find("[data-target='" + escape(window.location.hash) + "']");
}
// Open last active channel according to the server
if (serverActive > 0 && target.length === 0) {
target = sidebar.find("[data-id='" + serverActive + "']");
}
// Open first available channel
if (target.length === 0) {
target = sidebar.find(".chan").first();
}
// If target channel is found, open it
if (target.length > 0) {
target.trigger("click", {
replaceHistory: true
});
return;
}
// Open the connect window
$("#footer .connect").trigger("click", {
pushState: false
});
}

View File

@ -33,8 +33,30 @@ socket.on("more", function(data) {
}
// Add the older messages
const documentFragment = render.buildChannelMessages(data.chan, type, data.messages);
chan.prepend(documentFragment).end();
const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages);
chan.prepend(documentFragment);
// Move unread marker to correct spot if needed
const unreadMarker = chan.find(".unread-marker");
const firstUnread = unreadMarker.data("unread-id");
if (firstUnread > 0) {
let first = chan.find("#msg-" + firstUnread);
if (!first.length) {
chan.prepend(unreadMarker);
} else {
const parent = first.parent();
if (parent.hasClass("condensed")) {
first = parent;
}
unreadMarker.data("unread-id", 0);
first.before(unreadMarker);
}
}
// restore scroll position
const position = chan.height() - heightOld;
@ -54,3 +76,22 @@ socket.on("more", function(data) {
.text("Show older messages")
.prop("disabled", false);
});
chat.on("click", ".show-more-button", function() {
const self = $(this);
const lastMessage = self.closest(".chat").find(".msg:not(.condensed)").first();
let lastMessageId = -1;
if (lastMessage.length > 0) {
lastMessageId = parseInt(lastMessage.attr("id").replace("msg-", ""), 10);
}
self
.text("Loading older messages…")
.prop("disabled", true);
socket.emit("more", {
target: self.data("id"),
lastId: lastMessageId
});
});

View File

@ -3,17 +3,27 @@
const $ = require("jquery");
const socket = require("../socket");
const render = require("../render");
const utils = require("../utils");
const options = require("../options");
const helpers_roundBadgeNumber = require("../libs/handlebars/roundBadgeNumber");
const chat = $("#chat");
const sidebar = $("#sidebar");
let pop;
try {
pop = new Audio();
pop.src = "audio/pop.ogg";
} catch (e) {
pop = {
play: $.noop
};
}
$("#play").on("click", () => pop.play());
socket.on("msg", function(data) {
if (window.requestIdleCallback) {
// During an idle period the user agent will run idle callbacks in FIFO order
// until either the idle period ends or there are no more idle callbacks eligible to be run.
// We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000});
} else {
processReceivedMessage(data);
}
// We set a maximum timeout of 2 seconds so that messages don't take too long to appear.
utils.requestIdleCallback(() => processReceivedMessage(data), 2000);
});
function processReceivedMessage(data) {
@ -32,14 +42,13 @@ function processReceivedMessage(data) {
render.appendMessage(
container,
targetId,
$(target).attr("data-type"),
channel.attr("data-type"),
data.msg
);
container.trigger("msg", [
target,
data
]).trigger("keepToBottom");
container.trigger("keepToBottom");
notifyMessage(targetId, channel, data);
var lastVisible = container.find("div:visible").last();
if (data.msg.self
@ -48,6 +57,7 @@ function processReceivedMessage(data) {
&& lastVisible.prev().hasClass("unread-marker"))) {
container
.find(".unread-marker")
.data("unread-id", 0)
.appendTo(container);
}
@ -63,4 +73,83 @@ function processReceivedMessage(data) {
}
});
}
if ((data.msg.type === "message" || data.msg.type === "action" || data.msg.type === "notice") && channel.hasClass("channel")) {
const nicks = channel.find(".users").data("nicks");
if (nicks) {
const find = nicks.indexOf(data.msg.from);
if (find !== -1) {
nicks.splice(find, 1);
nicks.unshift(data.msg.from);
}
}
}
}
function notifyMessage(targetId, channel, msg) {
const unread = msg.unread;
msg = msg.msg;
if (msg.self) {
return;
}
const button = sidebar.find(".chan[data-id='" + targetId + "']");
if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) {
if (!document.hasFocus() || !channel.hasClass("active")) {
if (options.notification) {
try {
pop.play();
} catch (exception) {
// On mobile, sounds can not be played without user interaction.
}
}
utils.toggleNotificationMarkers(true);
if (options.desktopNotifications && Notification.permission === "granted") {
let title;
let body;
if (msg.type === "invite") {
title = "New channel invite:";
body = msg.from + " invited you to " + msg.channel;
} else {
title = msg.from;
if (!button.hasClass("query")) {
title += " (" + button.data("title").trim() + ")";
}
if (msg.type === "message") {
title += " says:";
}
body = msg.text.replace(/\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|[\x00-\x1F]|\x7F/g, "").trim();
}
try {
const notify = new Notification(title, {
body: body,
icon: "img/logo-64.png",
tag: `lounge-${targetId}`
});
notify.addEventListener("click", function() {
window.focus();
button.click();
this.close();
});
} catch (exception) {
// `new Notification(...)` is not supported and should be silenced.
}
}
}
}
if (!unread || button.hasClass("active")) {
return;
}
const badge = button.find(".badge").html(helpers_roundBadgeNumber(unread));
if (msg.highlight) {
badge.addClass("highlight");
}
}

View File

@ -3,9 +3,9 @@
const $ = require("jquery");
const renderPreview = require("../renderPreview");
const socket = require("../socket");
const utils = require("../utils");
socket.on("msg:preview", function(data) {
const msg = $("#msg-" + data.id);
renderPreview(data.preview, msg);
// Previews are not as important, we can wait longer for them to appear
utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000);
});

View File

@ -6,7 +6,7 @@ const render = require("../render");
const sidebar = $("#sidebar");
socket.on("network", function(data) {
render.renderNetworks(data);
render.renderNetworks(data, true);
sidebar.find(".chan")
.last()
@ -14,11 +14,9 @@ socket.on("network", function(data) {
$("#connect")
.find(".btn")
.prop("disabled", false)
.end();
.prop("disabled", false);
});
socket.on("network_changed", function(data) {
sidebar.find("#network-" + data.network).data("options", data.serverOptions);
});

View File

@ -6,12 +6,12 @@ const sidebar = $("#sidebar");
socket.on("quit", function(data) {
const id = data.network;
sidebar.find("#network-" + id)
.remove()
.end();
sidebar.find("#network-" + id).remove();
const chan = sidebar.find(".chan")
.eq(0)
.trigger("click");
if (chan.length === 0) {
sidebar.find(".empty").show();
}

View File

@ -2,54 +2,54 @@
const $ = require("jquery");
const io = require("socket.io-client");
const utils = require("./utils");
const path = window.location.pathname + "socket.io/";
const status = $("#loading-page-message, #connection-error");
const socket = io({
transports: $(document.body).data("transports"),
path: path,
autoConnect: false,
reconnection: false
reconnection: !$(document.body).hasClass("public")
});
[
"connect_error",
"connect_failed",
"disconnect",
"error",
].forEach(function(e) {
socket.on(e, function(data) {
$("#loading-page-message").text("Connection failed: " + data);
$("#connection-error").addClass("shown").one("click", function() {
window.onbeforeunload = null;
window.location.reload();
});
socket.on("disconnect", handleDisconnect);
socket.on("connect_error", handleDisconnect);
socket.on("error", handleDisconnect);
// Disables sending a message by pressing Enter. `off` is necessary to
// cancel `inputhistory`, which overrides hitting Enter. `on` is then
// necessary to avoid creating new lines when hitting Enter without Shift.
// This is fairly hacky but this solution is not permanent.
$("#input").off("keydown").on("keydown", function(event) {
if (event.which === 13 && !event.shiftKey) {
event.preventDefault();
}
});
// Hides the "Send Message" button
$("#submit").remove();
console.error(data);
});
socket.on("reconnecting", function(attempt) {
status.text(`Reconnecting… (attempt ${attempt})`);
});
socket.on("connecting", function() {
$("#loading-page-message").text("Connecting…");
status.text("Connecting…");
});
socket.on("connect", function() {
$("#loading-page-message").text("Finalizing connection…");
// Clear send buffer when reconnecting, socket.io would emit these
// immediately upon connection and it will have no effect, so we ensure
// nothing is sent to the server that might have happened.
socket.sendBuffer = [];
status.text("Finalizing connection…");
});
socket.on("authorized", function() {
$("#loading-page-message").text("Authorized, loading messages…");
status.text("Loading messages…");
});
function handleDisconnect(data) {
const message = data.message || data;
status.text(`Waiting to reconnect… (${message})`).addClass("shown");
$(".show-more-button, #input").prop("disabled", true);
$("#submit").hide();
// If the server shuts down, socket.io skips reconnection
// and we have to manually call connect to start the process
if (socket.io.skipReconnect) {
utils.requestIdleCallback(() => socket.connect(), 2000);
}
}
module.exports = socket;

View File

@ -4,16 +4,25 @@ const $ = require("jquery");
const chat = $("#chat");
const input = $("#input");
var serverHash = -1;
var lastMessageId = -1;
module.exports = {
findCurrentNetworkChan,
clear,
collapse,
expand,
join,
serverHash,
lastMessageId,
confirmExit,
forceFocus,
move,
resetHeight,
setNick,
toggleNickEditor,
toggleNotificationMarkers
toggleNotificationMarkers,
requestIdleCallback,
};
function findCurrentNetworkChan(name) {
@ -42,6 +51,26 @@ function clear() {
chat.find(".active")
.find(".show-more").addClass("show").end()
.find(".messages .msg, .date-marker-container").remove();
return true;
}
function collapse() {
$(".chan.active .toggle-button.opened").click();
return true;
}
function expand() {
$(".chan.active .toggle-button:not(.opened)").click();
return true;
}
function join(channel) {
var chan = findCurrentNetworkChan(channel);
if (chan.length) {
chan.click();
return true;
}
}
function toggleNickEditor(toggle) {
@ -90,3 +119,13 @@ function move(array, old_index, new_index) {
array.splice(new_index, 0, array.splice(old_index, 1)[0]);
return array;
}
function requestIdleCallback(callback, timeout) {
if (window.requestIdleCallback) {
// During an idle period the user agent will run idle callbacks in FIFO order
// until either the idle period ends or there are no more idle callbacks eligible to be run.
window.requestIdleCallback(callback, {timeout: timeout});
} else {
callback();
}
}

View File

@ -9,16 +9,30 @@ self.addEventListener("push", function(event) {
const payload = event.data.json();
if (payload.type === "notification") {
event.waitUntil(
self.registration.showNotification(payload.title, {
badge: "img/logo-64.png",
icon: "img/touch-icon-192x192.png",
body: payload.body,
timestamp: payload.timestamp,
})
);
if (payload.type !== "notification") {
return;
}
// get current notification, close it, and draw new
event.waitUntil(
self.registration
.getNotifications({
tag: `chan-${payload.chanId}`
})
.then((notifications) => {
for (const notification of notifications) {
notification.close();
}
return self.registration.showNotification(payload.title, {
tag: `chan-${payload.chanId}`,
badge: "img/logo-64.png",
icon: "img/touch-icon-192x192.png",
body: payload.body,
timestamp: payload.timestamp,
});
})
);
});
self.addEventListener("notificationclick", function(event) {

View File

@ -65,12 +65,8 @@ a:hover,
background: #00ff0e;
}
.btn-reconnect {
#connection-error {
background: #f00;
color: #fff;
border: 0;
border-radius: 0;
margin: 0;
}
#settings .opt {

View File

@ -46,14 +46,6 @@ body {
border-radius: 2px;
}
.btn-reconnect {
background: #e74c3c;
color: #fff;
border: 0;
border-radius: 0;
margin: 0;
}
@media (max-width: 768px) {
#sidebar {
left: -220px;

View File

@ -29,14 +29,6 @@ body {
background: #333c4a;
}
#windows .header .topic,
#windows #form .input,
.messages .msg,
.sidebar {
font-family: inherit;
font-size: 13px;
}
#chat .count {
background-color: #2e3642;
}
@ -213,14 +205,6 @@ body {
color: #99a2b4;
}
.btn-reconnect {
background: #e74c3c;
color: #fff;
border: 0;
border-radius: 0;
margin: 0;
}
/* Form elements */
#chat-container ::-moz-placeholder {

View File

@ -30,14 +30,6 @@ body {
background: #3f3f3f;
}
#windows .header .topic,
#windows #form .input,
.messages .msg,
.sidebar {
font-family: inherit;
font-size: 13px;
}
#settings,
#sign-in,
#connect .title {
@ -240,14 +232,6 @@ body {
color: #d2d39b;
}
.btn-reconnect {
background: #e74c3c;
color: #fff;
border: 0;
border-radius: 0;
margin: 0;
}
/* Form elements */
#chat-container ::-moz-placeholder {

View File

@ -0,0 +1,3 @@
{{> ../user_name nick=from}}
is away
<i class="away-message">({{{parse text}}})</i>

View File

@ -0,0 +1,2 @@
{{> ../user_name nick=from}}
is back

View File

@ -1,6 +1,6 @@
{{> ../user_name nick=from}}
{{> ../user_name nick=from.nick mode=from.mode}}
has kicked
{{> ../user_name nick=target mode=""}}
{{> ../user_name nick=target.nick mode=target.mode}}
{{#if text}}
<i class="part-reason">({{{parse text}}})</i>
{{/if}}

View File

@ -1,5 +1,5 @@
<div class="date-marker-container tooltipped tooltipped-s" data-timestamp="{{msgDate}}" aria-label="{{localedate msgDate}}">
<div class="date-marker-container tooltipped tooltipped-s" data-time="{{time}}" aria-label="{{localedate time}}">
<div class="date-marker">
<span class="date-marker-text" data-label="{{friendlydate msgDate}}"></span>
<span class="date-marker-text" data-label="{{friendlydate time}}"></span>
</div>
</div>

View File

@ -3,6 +3,8 @@
module.exports = {
actions: {
action: require("./actions/action.tpl"),
away: require("./actions/away.tpl"),
back: require("./actions/back.tpl"),
ban_list: require("./actions/ban_list.tpl"),
channel_list: require("./actions/channel_list.tpl"),
ctcp: require("./actions/ctcp.tpl"),

View File

@ -1,5 +1,5 @@
{{#preview}}
<button class="toggle-button {{#if shown}} opened{{/if}}"
<button class="toggle-button toggle-preview {{#if shown}} opened{{/if}}"
data-url="{{link}}"
{{#equal type "image"}}
aria-label="Toggle image preview"

View File

@ -51,11 +51,12 @@ module.exports = {
//
// Set the default theme.
// Find out how to add new themes at https://thelounge.github.io/docs/packages/themes
//
// @type string
// @default "themes/example.css"
// @default "example"
//
theme: "themes/example.css",
theme: "example",
//
// Prefetch URLs
@ -89,12 +90,12 @@ module.exports = {
// Prefetch URLs Image Preview size limit
//
// If prefetch is enabled, The Lounge will only display content under the maximum size.
// Specified value is in kilobytes. Default value is 512 kilobytes.
// Specified value is in kilobytes. Default value is 2048 kilobytes.
//
// @type int
// @default 512
// @default 2048
//
prefetchMaxImageSize: 512,
prefetchMaxImageSize: 2048,
//
// Display network
@ -365,6 +366,30 @@ module.exports = {
// @type object
// @default {}
//
// The authentication process works as follows:
//
// 1. Lounge connects to the LDAP server with its system credentials
// 2. It performs a LDAP search query to find the full DN associated to the
// user requesting to log in.
// 3. Lounge tries to connect a second time, but this time using the user's
// DN and password. Auth is validated iff this connection is successful.
//
// The search query takes a couple of parameters in `searchDN`:
// - a base DN `searchDN/base`. Only children nodes of this DN will be likely
// to be returned;
// - a search scope `searchDN/scope` (see LDAP documentation);
// - the query itself, build as (&(<primaryKey>=<username>) <filter>)
// where <username> is the user name provided in the log in request,
// <primaryKey> is provided by the config and <fitler> is a filtering complement
// also given in the config, to filter for instance only for nodes of type
// inetOrgPerson, or whatever LDAP search allows.
//
// Alternatively, you can specify the `bindDN` parameter. This will make the lounge
// ignore searchDN options and assume that the user DN is always:
// <bindDN>,<primaryKey>=<username>
// where <username> is the user name provided in the log in request, and <bindDN>
// and <primaryKey> are provided by the config.
//
ldap: {
//
// Enable LDAP user authentication
@ -382,11 +407,25 @@ module.exports = {
url: "ldaps://example.com",
//
// LDAP base dn
// LDAP connection tls options (only used if scheme is ldaps://)
//
// @type object (see nodejs' tls.connect() options)
// @default {}
//
// Example:
// You can use this option in order to force the use of IPv6:
// {
// host: 'my::ip::v6',
// servername: 'example.com'
// }
tlsOptions: {},
//
// LDAP base dn, alternative to searchDN
//
// @type string
//
baseDN: "ou=accounts,dc=example,dc=com",
// baseDN: "ou=accounts,dc=example,dc=com",
//
// LDAP primary key
@ -394,7 +433,58 @@ module.exports = {
// @type string
// @default "uid"
//
primaryKey: "uid"
primaryKey: "uid",
//
// LDAP search dn settings. This defines the procedure by which the
// lounge first look for user DN before authenticating her.
// Ignored if baseDN is specified
//
// @type object
//
searchDN: {
//
// LDAP searching bind DN
// This bind DN is used to query the server for the DN of the user.
// This is supposed to be a system user that has access in read only to
// the DNs of the people that are allowed to log in.
//
// @type string
//
rootDN: "cn=thelounge,ou=system-users,dc=example,dc=com",
//
// Password of the lounge LDAP system user
//
// @type string
//
rootPassword: "1234",
//
// LDAP filter
//
// @type string
// @default "uid"
//
filter: "(objectClass=person)(memberOf=ou=accounts,dc=example,dc=com)",
//
// LDAP search base (search only within this node)
//
// @type string
//
base: "dc=example,dc=com",
//
// LDAP search scope
//
// @type string
// @default "sub"
//
scope: "sub"
}
},
// Extra debugging

View File

@ -8,9 +8,11 @@ process.chdir(__dirname);
// Doing this check as soon as possible allows us to avoid ES6 parser errors or other issues
var pkg = require("./package.json");
if (!require("semver").satisfies(process.version, pkg.engines.node)) {
/* eslint-disable no-console */
console.error("=== WARNING!");
console.error("=== The oldest supported Node.js version is", pkg.engines.node);
console.error("=== We strongly encourage you to upgrade, see https://nodejs.org/en/download/package-manager/ for more details\n");
/* eslint-enable no-console */
}
require("./src/command-line");

View File

@ -47,19 +47,20 @@
"event-stream": "3.3.4",
"express": "4.15.4",
"express-handlebars": "3.0.0",
"fs-extra": "4.0.1",
"fs-extra": "4.0.2",
"irc-framework": "2.9.1",
"ldapjs": "1.0.1",
"lodash": "4.17.4",
"moment": "2.18.1",
"package-json": "4.0.1",
"read": "1.0.7",
"request": "2.81.0",
"request": "2.82.0",
"semver": "5.4.1",
"socket.io": "1.7.4",
"spdy": "3.4.7",
"ua-parser-js": "0.7.14",
"urijs": "1.18.12",
"web-push": "3.2.2"
"web-push": "3.2.3"
},
"devDependencies": {
"babel-core": "6.26.0",
@ -68,7 +69,7 @@
"chai": "4.1.2",
"css.escape": "1.5.1",
"emoji-regex": "6.5.1",
"eslint": "4.5.0",
"eslint": "4.7.2",
"font-awesome": "4.7.0",
"fuzzy": "0.1.3",
"handlebars": "4.0.10",
@ -77,13 +78,13 @@
"jquery": "3.2.1",
"jquery-textcomplete": "1.8.4",
"jquery-ui": "1.12.1",
"mocha": "3.5.0",
"mocha": "3.5.3",
"mousetrap": "1.6.1",
"npm-run-all": "4.1.1",
"nyc": "11.1.0",
"nyc": "11.2.1",
"socket.io-client": "1.7.4",
"stylelint": "8.0.0",
"stylelint": "8.1.1",
"stylelint-config-standard": "17.0.0",
"webpack": "3.5.5"
"webpack": "3.6.0"
}
}

View File

@ -1,25 +1,27 @@
"use strict";
var fs = require("fs-extra");
const colors = require("colors/safe");
const fs = require("fs-extra");
const log = require("../src/log");
var srcDir = "./node_modules/font-awesome/fonts/";
var destDir = "./client/fonts/";
var fonts = [
const srcDir = "./node_modules/font-awesome/fonts/";
const destDir = "./client/fonts/";
const fonts = [
"fontawesome-webfont.woff",
"fontawesome-webfont.woff2"
];
fs.ensureDir(destDir, function(dirErr) {
fs.ensureDir(destDir, (dirErr) => {
if (dirErr) {
console.error(dirErr);
log.error(dirErr);
}
fonts.forEach(function(font) {
fs.copy(srcDir + font, destDir + font, function(err) {
fonts.forEach((font) => {
fs.copy(srcDir + font, destDir + font, (err) => {
if (err) {
console.error(err);
log.error(err);
} else {
console.log(font + " successfully installed.");
log.info(colors.bold(font) + " successfully installed.");
}
});
});

View File

@ -5,7 +5,6 @@ var colors = require("colors/safe");
var pkg = require("../package.json");
var Chan = require("./models/chan");
var crypto = require("crypto");
var userLog = require("./userLog");
var Msg = require("./models/msg");
var Network = require("./models/network");
var ircFramework = require("irc-framework");
@ -16,6 +15,7 @@ module.exports = Client;
var id = 0;
var events = [
"away",
"connection",
"unhandled",
"banlist",
@ -114,23 +114,6 @@ Client.prototype.emit = function(event, data) {
if (this.sockets !== null) {
this.sockets.in(this.id).emit(event, data);
}
if (this.config.log === true) {
if (event === "msg") {
var target = this.find(data.chan);
if (target) {
var chan = target.chan.name;
if (target.chan.type === Chan.Type.LOBBY) {
chan = target.network.host;
}
userLog.write(
this.name,
target.network.host,
chan,
data.msg
);
}
}
}
};
Client.prototype.find = function(channelId) {
@ -412,10 +395,20 @@ Client.prototype.more = function(data) {
}
const chan = target.chan;
const index = chan.messages.findIndex((val) => val.id === data.lastId);
let messages = [];
let index = 0;
// If we don't find the requested message, send an empty array
const messages = index > 0 ? chan.messages.slice(Math.max(0, index - 100), index) : [];
// If client requests -1, send last 100 messages
if (data.lastId < 0) {
index = chan.messages.length;
} else {
index = chan.messages.findIndex((val) => val.id === data.lastId);
}
// If requested id is not found, an empty array will be sent
if (index > 0) {
messages = chan.messages.slice(Math.max(0, index - 100), index);
}
client.emit("more", {
chan: chan.id,
@ -437,7 +430,7 @@ Client.prototype.open = function(socketId, target) {
target.chan.firstUnread = 0;
target.chan.unread = 0;
target.chan.highlight = false;
target.chan.highlight = 0;
this.attachedClients[socketId].openChannel = target.chan.id;
this.lastActiveChannel = target.chan.id;

View File

@ -32,6 +32,7 @@ require("./add");
require("./remove");
require("./reset");
require("./edit");
require("./install");
program.parse(process.argv);

View File

@ -0,0 +1,82 @@
"use strict";
const colors = require("colors/safe");
const program = require("commander");
const Helper = require("../helper");
const Utils = require("./utils");
program
.command("install <package>")
.description("Install a theme or a package")
.on("--help", Utils.extraHelp)
.action(function(packageName) {
const fs = require("fs");
const fsextra = require("fs-extra");
const path = require("path");
const child = require("child_process");
const packageJson = require("package-json");
if (!fs.existsSync(Helper.CONFIG_PATH)) {
log.error(`${Helper.CONFIG_PATH} does not exist.`);
return;
}
packageJson(packageName, {
fullMetadata: true
}).then((json) => {
if (!("lounge" in json)) {
log.error(`${colors.red(packageName)} does not have The Lounge metadata.`);
process.exit(1);
}
log.info(`Installing ${colors.green(packageName)}...`);
const packagesPath = Helper.getPackagesPath();
const packagesParent = path.dirname(packagesPath);
const packagesConfig = path.join(packagesParent, "package.json");
// Create node_modules folder, otherwise npm will start walking upwards to find one
fsextra.ensureDirSync(packagesPath);
// Create package.json with private set to true to avoid npm warnings
fs.writeFileSync(packagesConfig, JSON.stringify({
private: true,
description: "Packages for The Lounge. All packages in node_modules directory will be automatically loaded.",
}, null, "\t"));
const npm = child.spawn(
process.platform === "win32" ? "npm.cmd" : "npm",
[
"install",
"--production",
"--no-save",
"--no-bin-links",
"--no-package-lock",
"--prefix",
packagesParent,
packageName
],
{
stdio: "inherit"
}
);
npm.on("error", (e) => {
log.error(`${e}`);
process.exit(1);
});
npm.on("close", (code) => {
if (code !== 0) {
log.error(`Failed to install ${colors.green(packageName)}. Exit code: ${code}`);
return;
}
log.info(`${colors.green(packageName)} has been successfully installed.`);
});
}).catch((e) => {
log.error(`${e}`);
process.exit(1);
});
});

View File

@ -15,7 +15,7 @@ class Utils {
"",
` LOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(Utils.defaultLoungeHome())}.`,
"",
].forEach((e) => console.log(e));
].forEach((e) => console.log(e)); // eslint-disable-line no-console
}
static defaultLoungeHome() {

View File

@ -12,6 +12,8 @@ const colors = require("colors/safe");
var Helper = {
config: null,
expandHome: expandHome,
getPackagesPath: getPackagesPath,
getPackageModulePath: getPackageModulePath,
getStoragePath: getStoragePath,
getUserConfigPath: getUserConfigPath,
getUserLogsPath: getUserLogsPath,
@ -82,6 +84,14 @@ function setHome(homePath) {
log.warn("debug option is now an object, see defaults file for more information.");
this.config.debug = {ircFramework: true};
}
// TODO: Remove in future release
// Backwards compatibility for old way of specifying themes in settings
if (this.config.theme.includes(".css")) {
log.warn(`Referring to CSS files in the ${colors.green("theme")} setting of ${colors.green(Helper.CONFIG_PATH)} is ${colors.bold("deprecated")} and will be removed in a future version.`);
} else {
this.config.theme = `themes/${this.config.theme}.css`;
}
}
function getUserConfigPath(name) {
@ -96,6 +106,14 @@ function getStoragePath() {
return path.join(this.HOME, "storage");
}
function getPackagesPath() {
return path.join(this.HOME, "packages", "node_modules");
}
function getPackageModulePath(packageName) {
return path.join(Helper.getPackagesPath(), packageName);
}
function ip2hex(address) {
// no ipv6 support
if (!net.isIPv4(address)) {

View File

@ -16,6 +16,7 @@ function timestamp(type, messageArgs) {
return messageArgs;
}
/* eslint-disable no-console */
exports.error = function() {
console.error.apply(console, timestamp(colors.red("[ERROR]"), arguments));
};
@ -31,6 +32,7 @@ exports.info = function() {
exports.debug = function() {
console.log.apply(console, timestamp(colors.green("[DEBUG]"), arguments));
};
/* eslint-enable no-console */
exports.prompt = (options, callback) => {
options.prompt = timestamp(colors.cyan("[PROMPT]"), [options.text]).join(" ");

View File

@ -2,6 +2,7 @@
var _ = require("lodash");
var Helper = require("../helper");
const userLog = require("../userLog");
const storage = require("../plugins/storage");
module.exports = Chan;
@ -13,7 +14,7 @@ Chan.Type = {
SPECIAL: "special",
};
var id = 0;
let id = 1;
function Chan(attr) {
_.defaults(this, attr, {
@ -25,7 +26,7 @@ function Chan(attr) {
type: Chan.Type.CHANNEL,
firstUnread: 0,
unread: 0,
highlight: false,
highlight: 0,
users: []
});
}
@ -57,6 +58,10 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) {
this.messages.push(msg);
if (client.config.log === true) {
writeUserLog.call(this, client, msg);
}
if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) {
const deleted = this.messages.splice(0, this.messages.length - Helper.config.maxHistory);
@ -73,7 +78,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) {
}
if (msg.highlight) {
this.highlight = true;
this.highlight++;
}
}
};
@ -127,3 +132,14 @@ Chan.prototype.toJSON = function() {
clone.messages = clone.messages.slice(-100);
return clone;
};
function writeUserLog(client, msg) {
const target = client.find(this.id);
userLog.write(
client.name,
target.network.host, // TODO: Fix #1392, multiple connections to same server results in duplicate logs
this.type === Chan.Type.LOBBY ? target.network.host : this.name,
msg
);
}

View File

@ -29,7 +29,9 @@ class Msg {
Msg.Type = {
UNHANDLED: "unhandled",
AWAY: "away",
ACTION: "action",
BACK: "back",
ERROR: "error",
INVITE: "invite",
JOIN: "join",

View File

@ -5,7 +5,7 @@ var Chan = require("./chan");
module.exports = Network;
var id = 0;
let id = 1;
function Network(attr) {
_.defaults(this, attr, {

View File

@ -7,6 +7,7 @@ module.exports = User;
function User(attr, prefixLookup) {
_.defaults(this, attr, {
modes: [],
away: "",
mode: "",
nick: "",
lastMessage: 0,

132
src/plugins/auth/ldap.js Normal file
View File

@ -0,0 +1,132 @@
"use strict";
const Helper = require("../../helper");
const ldap = require("ldapjs");
function ldapAuthCommon(user, bindDN, password, callback) {
const config = Helper.config;
const ldapclient = ldap.createClient({
url: config.ldap.url,
tlsOptions: config.ldap.tlsOptions
});
ldapclient.on("error", function(err) {
log.error(`Unable to connect to LDAP server: ${err}`);
callback(!err);
});
ldapclient.bind(bindDN, password, function(err) {
ldapclient.unbind();
callback(!err);
});
}
function simpleLdapAuth(user, password, callback) {
if (!user) {
return callback(false);
}
const config = Helper.config;
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
const bindDN = `${config.ldap.primaryKey}=${userDN},${config.ldap.baseDN}`;
log.info(`Auth against LDAP ${config.ldap.url} with provided bindDN ${bindDN}`);
ldapAuthCommon(user, bindDN, password, callback);
}
/**
* LDAP auth using initial DN search (see config comment for ldap.searchDN)
*/
function advancedLdapAuth(user, password, callback) {
if (!user) {
return callback(false);
}
const config = Helper.config;
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
const ldapclient = ldap.createClient({
url: config.ldap.url,
tlsOptions: config.ldap.tlsOptions
});
const base = config.ldap.searchDN.base;
const searchOptions = {
scope: config.ldap.searchDN.scope,
filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`,
attributes: ["dn"]
};
ldapclient.on("error", function(err) {
log.error(`Unable to connect to LDAP server: ${err}`);
callback(!err);
});
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function(err) {
if (err) {
log.error("Invalid LDAP root credentials");
ldapclient.unbind();
callback(false);
} else {
ldapclient.search(base, searchOptions, function(err2, res) {
if (err2) {
log.warning(`User not found: ${userDN}`);
ldapclient.unbind();
callback(false);
} else {
let found = false;
res.on("searchEntry", function(entry) {
found = true;
const bindDN = entry.objectName;
log.info(`Auth against LDAP ${config.ldap.url} with found bindDN ${bindDN}`);
ldapclient.unbind();
ldapAuthCommon(user, bindDN, password, callback);
});
res.on("error", function(err3) {
log.error(`LDAP error: ${err3}`);
callback(false);
});
res.on("end", function() {
if (!found) {
callback(false);
}
});
}
});
}
});
}
function ldapAuth(manager, client, user, password, callback) {
// TODO: Enable the use of starttls() as an alternative to ldaps
// TODO: move this out of here and get rid of `manager` and `client` in
// auth plugin API
function callbackWrapper(valid) {
if (valid && !client) {
manager.addUser(user, null);
}
callback(valid);
}
let auth;
if ("baseDN" in Helper.config.ldap) {
auth = simpleLdapAuth;
} else {
auth = advancedLdapAuth;
}
return auth(user, password, callbackWrapper);
}
function isLdapEnabled() {
return !Helper.config.public && Helper.config.ldap.enable;
}
module.exports = {
auth: ldapAuth,
isEnabled: isLdapEnabled
};

42
src/plugins/auth/local.js Normal file
View File

@ -0,0 +1,42 @@
"use strict";
const Helper = require("../../helper");
const colors = require("colors/safe");
function localAuth(manager, client, user, password, callback) {
// If no user is found, or if the client has not provided a password,
// fail the authentication straight away
if (!client || !password) {
return callback(false);
}
// If this user has no password set, fail the authentication
if (!client.config.password) {
log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`);
return callback(false);
}
Helper.password
.compare(password, client.config.password)
.then((matching) => {
if (matching && Helper.password.requiresUpdate(client.config.password)) {
const hash = Helper.password.hash(password);
client.setPassword(hash, (success) => {
if (success) {
log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`);
}
});
}
callback(matching);
}).catch((error) => {
log.error(`Error while checking users password. Error: ${error}`);
});
}
module.exports = {
auth: localAuth,
isEnabled: () => true
};

View File

@ -9,7 +9,17 @@ exports.commands = ["close", "leave", "part"];
exports.allowDisconnected = true;
exports.input = function(network, chan, cmd, args) {
if (chan.type === Chan.Type.LOBBY) {
let target = args.length === 0 ? chan : _.find(network.channels, {name: args[0]});
let partMessage = args.length <= 1 ? Helper.config.leaveMessage : args.slice(1).join(" ");
if (typeof target === "undefined") {
// In this case, we assume that the word args[0] is part of the leave
// message and we part the current chan.
target = chan;
partMessage = args.join(" ");
}
if (target.type === Chan.Type.LOBBY) {
chan.pushMessage(this, new Msg({
type: Msg.Type.ERROR,
text: "You can not part from networks, use /quit instead."
@ -17,18 +27,17 @@ exports.input = function(network, chan, cmd, args) {
return;
}
network.channels = _.without(network.channels, chan);
chan.destroy();
network.channels = _.without(network.channels, target);
target.destroy();
this.emit("part", {
chan: chan.id
chan: target.id
});
if (chan.type === Chan.Type.CHANNEL) {
if (target.type === Chan.Type.CHANNEL) {
this.save();
if (network.irc) {
const partMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage;
network.irc.part(chan.name, partMessage);
network.irc.part(target.name, partMessage);
}
}

View File

@ -0,0 +1,30 @@
"use strict";
const _ = require("lodash");
const Msg = require("../../models/msg");
module.exports = function(irc, network) {
const client = this;
irc.on("away", (data) => {
const away = data.message;
network.channels.forEach((chan) => {
const user = _.find(chan.users, {nick: data.nick});
if (!user || user.away === away) {
return;
}
const msg = new Msg({
type: away ? Msg.Type.AWAY : Msg.Type.BACK,
text: away || "",
time: data.time,
from: data.nick,
mode: user.mode
});
chan.pushMessage(client, msg);
user.away = away;
});
});
};

View File

@ -11,12 +11,13 @@ module.exports = function(irc, network) {
return;
}
const user = chan.findUser(data.kicked);
const kicker = chan.findUser(data.nick);
const target = chan.findUser(data.kicked);
if (data.kicked === irc.user.nick) {
chan.users = [];
} else {
chan.users = _.without(chan.users, user);
chan.users = _.without(chan.users, target);
}
client.emit("users", {
@ -26,9 +27,8 @@ module.exports = function(irc, network) {
var msg = new Msg({
type: Msg.Type.KICK,
time: data.time,
mode: user.mode,
from: data.nick,
target: data.kicked,
from: kicker,
target: target,
text: data.message || "",
highlight: data.kicked === irc.user.nick,
self: data.nick === irc.user.nick

View File

@ -106,20 +106,29 @@ module.exports = function(irc, network) {
// Do not send notifications for messages older than 15 minutes (znc buffer for example)
if (highlight && (!data.time || data.time > Date.now() - 900000)) {
let title = data.nick;
let title = chan.name;
let body = Helper.cleanIrcMessage(data.message);
// In channels, prepend sender nickname to the message
if (chan.type !== Chan.Type.QUERY) {
title += ` (${chan.name}) mentioned you`;
} else {
title += " sent you a message";
body = `${data.nick}: ${body}`;
}
// If a channel is active on any client, highlight won't increment and notification will say (0 mention)
if (chan.highlight > 0) {
title += ` (${chan.highlight} ${chan.type === Chan.Type.QUERY ? "new message" : "mention"}${chan.highlight > 1 ? "s" : ""})`;
}
if (chan.highlight > 1) {
body += `\n\n… and ${chan.highlight - 1} other message${chan.highlight > 2 ? "s" : ""}`;
}
client.manager.webPush.push(client, {
type: "notification",
chanId: chan.id,
timestamp: data.time || Date.now(),
title: `The Lounge: ${title}`,
body: Helper.cleanIrcMessage(data.message)
title: title,
body: body
}, true);
}
}

85
src/plugins/themes.js Normal file
View File

@ -0,0 +1,85 @@
"use strict";
const fs = require("fs");
const Helper = require("../helper");
const colors = require("colors/safe");
const path = require("path");
const _ = require("lodash");
const themes = new Map();
module.exports = {
getAll: getAll,
getFilename: getFilename
};
fs.readdir("client/themes/", (err, builtInThemes) => {
if (err) {
return;
}
builtInThemes
.filter((theme) => theme.endsWith(".css"))
.map(makeLocalThemeObject)
.forEach((theme) => themes.set(theme.name, theme));
});
fs.readdir(Helper.getPackagesPath(), (err, packages) => {
if (err) {
return;
}
packages
.map(makePackageThemeObject)
.forEach((theme) => {
if (theme) {
themes.set(theme.name, theme);
}
});
});
function getAll() {
return _.sortBy(Array.from(themes.values()), "displayName");
}
function getFilename(module) {
if (themes.has(module)) {
return themes.get(module).filename;
}
}
function makeLocalThemeObject(css) {
const themeName = css.slice(0, -4);
return {
displayName: themeName.charAt(0).toUpperCase() + themeName.slice(1),
filename: path.join(__dirname, "..", "client", "themes", `${themeName}.css`),
name: themeName
};
}
function getModuleInfo(packageName) {
let module;
try {
module = require(Helper.getPackageModulePath(packageName));
} catch (e) {
log.warn(`Specified theme ${colors.yellow(packageName)} is not installed in packages directory`);
return;
}
if (!module.lounge) {
log.warn(`Specified theme ${colors.yellow(packageName)} doesn't have required information.`);
return;
}
return module.lounge;
}
function makePackageThemeObject(moduleName) {
const module = getModuleInfo(moduleName);
if (!module || module.type !== "theme") {
return;
}
const modulePath = Helper.getPackageModulePath(moduleName);
const displayName = module.name || moduleName;
const filename = path.join(modulePath, module.css);
return {
displayName: displayName,
filename: filename,
name: moduleName
};
}

View File

@ -11,10 +11,20 @@ var path = require("path");
var io = require("socket.io");
var dns = require("dns");
var Helper = require("./helper");
var ldap = require("ldapjs");
var colors = require("colors/safe");
const net = require("net");
const Identification = require("./identification");
const themes = require("./plugins/themes");
// The order defined the priority: the first available plugin is used
// ALways keep local auth in the end, which should always be enabled.
const authPlugins = [
require("./plugins/auth/ldap"),
require("./plugins/auth/local"),
];
// A random number that will force clients to reload the page if it differs
const serverHash = Math.floor(Date.now() * Math.random());
var manager = null;
@ -45,6 +55,15 @@ module.exports = function() {
.set("view engine", "html")
.set("views", path.join(__dirname, "..", "client"));
app.get("/themes/:theme.css", (req, res) => {
const themeName = req.params.theme;
const theme = themes.getFilename(themeName);
if (theme === undefined) {
return res.status(404).send("Not found");
}
return res.sendFile(theme);
});
var config = Helper.config;
var server = null;
@ -94,6 +113,8 @@ module.exports = function() {
};
}
server.on("error", (err) => log.error(`${err}`));
server.listen(listenParams, () => {
if (typeof listenParams === "string") {
log.info("Available on socket " + colors.green(listenParams));
@ -117,7 +138,10 @@ module.exports = function() {
if (config.public) {
performAuthentication.call(socket, {});
} else {
socket.emit("auth", {success: true});
socket.emit("auth", {
serverHash: serverHash,
success: true,
});
socket.on("auth", performAuthentication);
}
});
@ -184,15 +208,7 @@ function index(req, res, next) {
Helper.config
);
data.gitCommit = Helper.getGitCommit();
data.themes = fs.readdirSync("client/themes/").filter(function(themeFile) {
return themeFile.endsWith(".css");
}).map(function(css) {
const filename = css.slice(0, -4);
return {
name: filename.charAt(0).toUpperCase() + filename.slice(1),
filename: filename
};
});
data.themes = themes.getAll();
const policies = [
"default-src *",
@ -215,7 +231,7 @@ function index(req, res, next) {
res.render("index", data);
}
function initializeClient(socket, client, token) {
function initializeClient(socket, client, token, lastMessage) {
socket.emit("authorized");
socket.on("disconnect", function() {
@ -379,11 +395,24 @@ function initializeClient(socket, client, token) {
socket.join(client.id);
const sendInitEvent = (tokenToSend) => {
let networks = client.networks;
if (lastMessage > -1) {
// We need a deep cloned object because we are going to remove unneeded messages
networks = _.cloneDeep(networks);
networks.forEach((network) => {
network.channels.forEach((channel) => {
channel.messages = channel.messages.filter((m) => m.id > lastMessage);
});
});
}
socket.emit("init", {
applicationServerKey: manager.webPush.vapidKeys.publicKey,
pushSubscription: client.config.sessions[token],
active: client.lastActiveChannel,
networks: client.networks,
networks: networks,
token: tokenToSend
});
};
@ -409,67 +438,11 @@ function initializeClient(socket, client, token) {
}
}
function localAuth(client, user, password, callback) {
// If no user is found, or if the client has not provided a password,
// fail the authentication straight away
if (!client || !password) {
return callback(false);
}
// If this user has no password set, fail the authentication
if (!client.config.password) {
log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`);
return callback(false);
}
Helper.password
.compare(password, client.config.password)
.then((matching) => {
if (matching && Helper.password.requiresUpdate(client.config.password)) {
const hash = Helper.password.hash(password);
client.setPassword(hash, (success) => {
if (success) {
log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`);
}
});
}
callback(matching);
}).catch((error) => {
log.error(`Error while checking users password. Error: ${error}`);
});
}
function ldapAuth(client, user, password, callback) {
var userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
var bindDN = Helper.config.ldap.primaryKey + "=" + userDN + "," + Helper.config.ldap.baseDN;
var ldapclient = ldap.createClient({
url: Helper.config.ldap.url
});
ldapclient.on("error", function(err) {
log.error("Unable to connect to LDAP server", err);
callback(!err);
});
ldapclient.bind(bindDN, password, function(err) {
if (!err && !client) {
if (!manager.addUser(user, null)) {
log.error("Unable to create new user", user);
}
}
ldapclient.unbind();
callback(!err);
});
}
function performAuthentication(data) {
const socket = this;
let client;
const finalInit = () => initializeClient(socket, client, data.token || null);
const finalInit = () => initializeClient(socket, client, data.token || null, data.lastMessage || -1);
const initClient = () => {
client.ip = getClientIp(socket.request);
@ -528,11 +501,16 @@ function performAuthentication(data) {
}
// Perform password checking
if (!Helper.config.public && Helper.config.ldap.enable) {
ldapAuth(client, data.user, data.password, authCallback);
} else {
localAuth(client, data.user, data.password, authCallback);
let auth = () => {
log.error("None of the auth plugins is enabled");
};
for (let i = 0; i < authPlugins.length; ++i) {
if (authPlugins[i].isEnabled()) {
auth = authPlugins[i].auth;
break;
}
}
auth(manager, client, data.user, data.password, authCallback);
}
function reverseDnsLookup(ip, callback) {

View File

@ -18,7 +18,7 @@ module.exports.write = function(user, network, chan, msg) {
var format = Helper.config.logs.format || "YYYY-MM-DD HH:mm:ss";
var tz = Helper.config.logs.timezone || "UTC+00:00";
var time = moment().utcOffset(tz).format(format);
var time = moment(msg.time).utcOffset(tz).format(format);
var line = `[${time}] `;
var type = msg.type.trim();

View File

@ -1,7 +1,7 @@
"use strict";
global.log = {
error: () => console.error.apply(console, arguments),
error: () => console.error.apply(console, arguments), // eslint-disable-line no-console
warn: () => {},
info: () => {},
debug: () => {},

148
test/plugins/auth/ldap.js Normal file
View File

@ -0,0 +1,148 @@
"use strict";
const ldapAuth = require("../../../src/plugins/auth/ldap");
const Helper = require("../../../src/helper");
const ldap = require("ldapjs");
const expect = require("chai").expect;
const user = "johndoe";
const wrongUser = "eve";
const correctPassword = "loremipsum";
const wrongPassword = "dolorsitamet";
const baseDN = "ou=accounts,dc=example,dc=com";
const primaryKey = "uid";
const serverPort = 1389;
function normalizeDN(dn) {
return ldap.parseDN(dn).toString();
}
function startLdapServer(callback) {
const server = ldap.createServer();
const searchConf = Helper.config.ldap.searchDN;
const userDN = primaryKey + "=" + user + "," + baseDN;
// Two users are authorized: john doe and the root user in case of
// advanced auth (the user that does the search for john's actual
// bindDN)
const authorizedUsers = {};
authorizedUsers[normalizeDN(searchConf.rootDN)] = searchConf.rootPassword;
authorizedUsers[normalizeDN(userDN)] = correctPassword;
function authorize(req, res, next) {
const bindDN = req.connection.ldap.bindDN;
if (bindDN in authorizedUsers) {
return next();
}
return next(new ldap.InsufficientAccessRightsError());
}
Object.keys(authorizedUsers).forEach(function(dn) {
server.bind(dn, function(req, res, next) {
const bindDN = req.dn.toString();
const password = req.credentials;
if (bindDN in authorizedUsers && authorizedUsers[bindDN] === password) {
req.connection.ldap.bindDN = req.dn;
res.end();
return next();
}
return next(new ldap.InsufficientAccessRightsError());
});
});
server.search(searchConf.base, authorize, function(req, res) {
const obj = {
dn: userDN,
attributes: {
objectclass: ["person", "top"],
cn: ["john doe"],
sn: ["johnny"],
uid: ["johndoe"],
memberof: [baseDN]
}
};
if (req.filter.matches(obj.attributes)) {
// TODO: check req.scope if ldapjs does not
res.send(obj);
}
res.end();
});
server.listen(serverPort, callback);
return server;
}
function testLdapAuth() {
// Create mock manager and client. When client is true, manager should not
// be used. But ideally the auth plugin should not use any of those.
const manager = {};
const client = true;
it("should successfully authenticate with correct password", function(done) {
ldapAuth.auth(manager, client, user, correctPassword, function(valid) {
expect(valid).to.equal(true);
done();
});
});
it("should fail to authenticate with incorrect password", function(done) {
ldapAuth.auth(manager, client, user, wrongPassword, function(valid) {
expect(valid).to.equal(false);
done();
});
});
it("should fail to authenticate with incorrect username", function(done) {
ldapAuth.auth(manager, client, wrongUser, correctPassword, function(valid) {
expect(valid).to.equal(false);
done();
});
});
}
describe("LDAP authentication plugin", function() {
before(function(done) {
this.server = startLdapServer(done);
});
after(function(done) {
this.server.close();
done();
});
beforeEach(function(done) {
Helper.config.public = false;
Helper.config.ldap.enable = true;
Helper.config.ldap.url = "ldap://localhost:" + String(serverPort);
Helper.config.ldap.primaryKey = primaryKey;
done();
});
describe("LDAP authentication availability", function() {
it("checks that the configuration is correctly tied to isEnabled()", function(done) {
Helper.config.ldap.enable = true;
expect(ldapAuth.isEnabled()).to.equal(true);
Helper.config.ldap.enable = false;
expect(ldapAuth.isEnabled()).to.equal(false);
done();
});
});
describe("Simple LDAP authentication (predefined DN pattern)", function() {
Helper.config.ldap.baseDN = baseDN;
testLdapAuth();
});
describe("Advanced LDAP authentication (DN found by a prior search query)", function() {
delete Helper.config.ldap.baseDN;
testLdapAuth();
});
});

View File

@ -37,6 +37,11 @@ describe("Server", () => {
describe("WebSockets", () => {
let client;
before((done) => {
Helper.config.public = true;
done();
});
beforeEach(() => {
client = io(webURL, {
path: "/socket.io/",

View File

@ -79,8 +79,6 @@ if (process.env.NODE_ENV === "production") {
sourceMap: true,
comments: false
}));
} else {
console.log("Building in development mode, bundles will not be minified.");
}
module.exports = config;