Compare commits

8 Commits

Author SHA1 Message Date
a31eb08ceb Use host networking in docker-compose 2025-12-29 19:45:16 -08:00
b47fa524c3 Remove Connect to network button from sidebar 2025-12-29 19:34:44 -08:00
ecb9fe6b33 fix: Add entrypoint script to fix volume permissions at runtime
- Add docker-entrypoint.sh that runs as root to fix mounted volume permissions
- Creates required subdirectories (logs, users, packages) before app starts
- Copies default config.js if missing
- Drops to node user via su-exec before running the app
- Update Dockerfile to use entrypoint and install su-exec
- Update docker-compose.yml with UID/GID mapping and separate volume mounts
- Wrap filesystem operations in try-catch to handle permission errors gracefully
2025-12-29 19:02:58 -08:00
fc2190c7cd fix: Wrap entire createPackagesFolder in try-catch for mounted volumes 2025-12-29 18:58:06 -08:00
8937658597 fix: Wrap packages node_modules mkdir in try-catch
Prevents crash on mounted volumes where mkdir may fail due to
permission restrictions.
2025-12-29 18:53:51 -08:00
6cd5f0fa81 fix: Handle chmod/mkdir permission errors on mounted volumes
Wrap chmod and mkdir with mode operations in try-catch blocks to
prevent EPERM errors when running in containers with mounted volumes.

macOS and some container runtimes don't allow chmod operations on
mounted directories, causing the app to crash on startup.
2025-12-29 18:41:37 -08:00
13164b89aa feat: Lock webchat to irc.supernets.org with simplified connect form
This commit configures Hard Lounge as a dedicated webchat client for
SuperNETs IRC, requiring only a nickname to connect.

- Set `public: true` to enable public mode (no user accounts required)
- Set `lockNetwork: true` to lock connections to irc.supernets.org only

Users will automatically connect to irc.supernets.org:6697 (TLS) and
join #superbowl upon entering a nickname.

- Added simplified connect form for public + lockNetwork mode
- Form now shows only the nickname field when both settings are enabled
- Hidden fields: server, port, TLS, username, realname, channels,
  leave message, authentication options
- Added CSS styling for proper spacing on simplified form

- Pinned to Node 20 Alpine (from lts-alpine) for compatibility
- Added py3-setuptools to fix distutils module error with Python 3.12
- Fixed file ownership with --chown=node:node on COPY commands
- Moved USER node directive after COPY to fix permission issues
- Pre-create /var/opt/hardlounge directory with correct ownership
2025-12-29 18:36:50 -08:00
hgw
8cc5eed920 Merge pull request 'Import upstream patches from The Lounge (Feb 2024), bump version to v4.4.1-2' (#2) from upstream-patches-202402 into master
Reviewed-on: #2
2024-02-01 19:41:48 -08:00
9 changed files with 225 additions and 160 deletions

View File

@@ -1,18 +1,21 @@
FROM node:lts-alpine
RUN apk add --no-cache --virtual=build-dependencies build-base git python3-dev && \
apk add --no-cache yarn
USER node
FROM node:20-alpine
RUN apk add --no-cache --virtual=build-dependencies build-base git python3-dev py3-setuptools && \
apk add --no-cache yarn su-exec
WORKDIR /var/opt/hardlounge-src
ENV THELOUNGE_HOME /var/opt/hardlounge
COPY package.json yarn.lock .
COPY --chown=node:node package.json yarn.lock .
USER node
RUN yarn install
COPY . .
COPY --chown=node:node . .
RUN NODE_ENV=production yarn build && \
yarn link && \
yarn --non-interactive cache clean && \
ln -s /var/opt/hardlounge-src/index.js /var/opt/hardlounge-src/hardlounge
USER root
RUN apk del --purge build-dependencies
USER node
RUN apk del --purge build-dependencies && \
mkdir -p /var/opt/hardlounge/logs /var/opt/hardlounge/users /var/opt/hardlounge/packages && \
chown -R node:node /var/opt/hardlounge
COPY --chmod=755 docker-entrypoint.sh /docker-entrypoint.sh
EXPOSE 9000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/var/opt/hardlounge-src/hardlounge", "start"]

View File

@@ -206,122 +206,143 @@
</div>
</template>
<h2>User preferences</h2>
<div class="connect-row">
<label for="connect:nick">Nick</label>
<input
id="connect:nick"
v-model="defaults.nick"
class="input nick"
name="nick"
pattern="[^\s:!@]+"
maxlength="100"
required
@input="onNickChanged"
/>
</div>
<template v-if="!config?.useHexIp">
<div class="connect-row">
<label for="connect:username">Username</label>
<!-- Simplified form for public + lockNetwork mode: only show nick -->
<template v-if="config?.lockNetwork && store.state.serverConfiguration?.public">
<div class="connect-row simple-nick">
<label for="connect:nick">Nick</label>
<input
id="connect:username"
ref="usernameInput"
v-model.trim="defaults.username"
class="input username"
name="username"
id="connect:nick"
v-model="defaults.nick"
class="input nick"
name="nick"
pattern="[^\s:!@]+"
maxlength="100"
required
@input="onNickChanged"
/>
</div>
</template>
<div class="connect-row">
<label for="connect:realname">Real name</label>
<input
id="connect:realname"
v-model.trim="defaults.realname"
class="input"
name="realname"
maxlength="300"
/>
</div>
<div class="connect-row">
<label for="connect:leaveMessage">Leave message</label>
<input
id="connect:leaveMessage"
v-model.trim="defaults.leaveMessage"
autocomplete="off"
class="input"
name="leaveMessage"
placeholder="Hard Lounge - https://git.supernets.org/supernets/hardlounge"
/>
</div>
<template v-if="defaults.uuid && !store.state.serverConfiguration?.public">
<!-- Full form for other modes -->
<template v-else>
<h2>User preferences</h2>
<div class="connect-row">
<label for="connect:commands">
Commands
<span
class="tooltipped tooltipped-ne tooltipped-no-delay"
aria-label="One /command per line.
Each command will be executed in
the server tab on new connection"
>
<button class="extra-help" />
</span>
</label>
<textarea
id="connect:commands"
ref="commandsInput"
autocomplete="off"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
class="input"
name="commands"
@input="resizeCommandsInput"
/>
</div>
</template>
<template v-else-if="!defaults.uuid">
<div class="connect-row">
<label for="connect:channels">Channels</label>
<label for="connect:nick">Nick</label>
<input
id="connect:channels"
v-model.trim="defaults.join"
class="input"
name="join"
id="connect:nick"
v-model="defaults.nick"
class="input nick"
name="nick"
pattern="[^\s:!@]+"
maxlength="100"
required
@input="onNickChanged"
/>
</div>
</template>
<template v-if="store.state.serverConfiguration?.public">
<template v-if="config?.lockNetwork">
<template v-if="!config?.useHexIp">
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label class="tls">
<input v-model="displayPasswordField" type="checkbox" />
I have a password
</label>
</div>
</div>
<div v-if="displayPasswordField" class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
ref="publicPassword"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
<label for="connect:username">Username</label>
<input
id="connect:username"
ref="usernameInput"
v-model.trim="defaults.username"
class="input username"
name="username"
maxlength="100"
/>
</div>
</template>
<div class="connect-row">
<label for="connect:realname">Real name</label>
<input
id="connect:realname"
v-model.trim="defaults.realname"
class="input"
name="realname"
maxlength="300"
/>
</div>
<div class="connect-row">
<label for="connect:leaveMessage">Leave message</label>
<input
id="connect:leaveMessage"
v-model.trim="defaults.leaveMessage"
autocomplete="off"
class="input"
name="leaveMessage"
placeholder="Hard Lounge - https://git.supernets.org/supernets/hardlounge"
/>
</div>
<template v-if="defaults.uuid && !store.state.serverConfiguration?.public">
<div class="connect-row">
<label for="connect:commands">
Commands
<span
class="tooltipped tooltipped-ne tooltipped-no-delay"
aria-label="One /command per line.
Each command will be executed in
the server tab on new connection"
>
<button class="extra-help" />
</span>
</label>
<textarea
id="connect:commands"
ref="commandsInput"
autocomplete="off"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
class="input"
name="commands"
@input="resizeCommandsInput"
/>
</div>
</template>
<template v-else-if="!defaults.uuid">
<div class="connect-row">
<label for="connect:channels">Channels</label>
<input
id="connect:channels"
v-model.trim="defaults.join"
class="input"
name="join"
/>
</div>
</template>
<template v-if="store.state.serverConfiguration?.public">
<template v-if="config?.lockNetwork">
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label class="tls">
<input v-model="displayPasswordField" type="checkbox" />
I have a password
</label>
</div>
</div>
<div v-if="displayPasswordField" class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
ref="publicPassword"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
</template>
</template>
<template v-else>
<!-- Authentication section only for private mode -->
<template v-if="!store.state.serverConfiguration?.public && !(config?.lockNetwork && store.state.serverConfiguration?.public)">
<h2 id="label-auth">Authentication</h2>
<div class="connect-row connect-auth" role="group" aria-labelledby="label-auth">
<label class="opt">
@@ -435,6 +456,15 @@ the server tab on new connection"
margin: 0;
user-select: text;
}
/* Simplified connect form styling */
#connect .connect-row.simple-nick {
margin-top: 20px;
}
#connect .connect-row.simple-nick label {
margin-bottom: 8px;
}
</style>
<script lang="ts">

View File

@@ -30,22 +30,6 @@
<NetworkList />
</div>
<footer id="footer">
<span
class="tooltipped tooltipped-n tooltipped-no-touch"
aria-label="Connect to network"
><router-link
v-slot:default="{navigate, isActive}"
to="/connect"
role="tab"
aria-controls="connect"
>
<button
:class="['icon', 'connect', {active: isActive}]"
:aria-selected="isActive"
@click="navigate"
@keypress.enter="navigate"
/> </router-link
></span>
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
><router-link
v-slot:default="{navigate, isActive}"

View File

@@ -16,7 +16,7 @@ module.exports = {
// channels and scrollbacks are available when they come back.
//
// This value is set to `false` by default.
public: false,
public: true,
// ### `host`
//
@@ -287,7 +287,7 @@ module.exports = {
// These fields will also be hidden from the UI.
//
// This value is set to `false` by default.
lockNetwork: false,
lockNetwork: true,
// ## User management

View File

@@ -2,7 +2,9 @@ services:
hardlounge:
image: git.supernets.org/supernets/hardlounge:latest
build: .
ports:
- "9000:9000"
user: "${UID}:${GID}"
network_mode: host
volumes:
- "$PWD/config:/var/opt/hardlounge"
- ./data/logs:/var/opt/hardlounge/logs
- ./data/packages:/var/opt/hardlounge/packages
- ./config:/var/opt/hardlounge

19
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
set -e
# Fix permissions on mounted volume at runtime
chown -R node:node /var/opt/hardlounge 2>/dev/null || true
chmod -R 755 /var/opt/hardlounge 2>/dev/null || true
# Create required subdirectories
mkdir -p /var/opt/hardlounge/logs /var/opt/hardlounge/users /var/opt/hardlounge/packages 2>/dev/null || true
chown -R node:node /var/opt/hardlounge 2>/dev/null || true
# Copy default config if it doesn't exist
if [ ! -f /var/opt/hardlounge/config.js ]; then
cp /var/opt/hardlounge-src/dist/defaults/config.js /var/opt/hardlounge/config.js 2>/dev/null || true
chown node:node /var/opt/hardlounge/config.js 2>/dev/null || true
fi
# Run as node user
exec su-exec node "$@"

View File

@@ -61,27 +61,32 @@ if (!Config.values.public) {
program.parse(argvWithoutOptions.operands.concat(argvWithoutOptions.unknown));
function createPackagesFolder() {
const packagesPath = Config.getPackagesPath();
const packagesConfig = path.join(packagesPath, "package.json");
try {
const packagesPath = Config.getPackagesPath();
const packagesConfig = path.join(packagesPath, "package.json");
// Create node_modules folder, otherwise yarn will start walking upwards to find one
fs.mkdirSync(path.join(packagesPath, "node_modules"), { recursive: true });
// Create node_modules folder, otherwise yarn will start walking upwards to find one
fs.mkdirSync(path.join(packagesPath, "node_modules"), { recursive: true });
// Create package.json with private set to true, if it doesn't exist already
if (!fs.existsSync(packagesConfig)) {
fs.writeFileSync(
packagesConfig,
JSON.stringify(
{
private: true,
description:
"Packages for Hard Lounge. Use `thelounge install <package>` command to add a package.",
dependencies: {},
},
null,
"\t"
)
);
// Create package.json with private set to true, if it doesn't exist already
if (!fs.existsSync(packagesConfig)) {
fs.writeFileSync(
packagesConfig,
JSON.stringify(
{
private: true,
description:
"Packages for Hard Lounge. Use `thelounge install <package>` command to add a package.",
dependencies: {},
},
null,
"\t"
)
);
}
} catch (e) {
// Ignore permission errors on mounted volumes
log.warn("Unable to create packages folder, package management may not work");
}
}

View File

@@ -22,16 +22,38 @@ program
function initalizeConfig() {
if (!fs.existsSync(Config.getConfigPath())) {
fs.mkdirSync(Config.getHomePath(), {recursive: true});
fs.chmodSync(Config.getHomePath(), "0700");
fs.copyFileSync(
path.resolve(path.join(__dirname, "..", "..", "defaults", "config.js")),
Config.getConfigPath()
);
log.info(`Configuration file created at ${colors.green(Config.getConfigPath())}.`);
try {
fs.mkdirSync(Config.getHomePath(), {recursive: true});
try {
fs.chmodSync(Config.getHomePath(), "0700");
} catch (e) {
// Ignore chmod errors (e.g., on mounted volumes)
}
fs.copyFileSync(
path.resolve(path.join(__dirname, "..", "..", "defaults", "config.js")),
Config.getConfigPath()
);
log.info(`Configuration file created at ${colors.green(Config.getConfigPath())}.`);
} catch (e) {
log.error(
"Unable to create config directory/file. Please ensure the config volume is writable or pre-create config.js"
);
process.exit(1);
}
}
fs.mkdirSync(Config.getUsersPath(), {recursive: true, mode: 0o700});
try {
fs.mkdirSync(Config.getUsersPath(), {recursive: true, mode: 0o700});
} catch (e) {
// Ignore permission errors on mounted volumes
try {
fs.mkdirSync(Config.getUsersPath(), {recursive: true});
} catch (e2) {
// Ignore if this also fails
}
}
}
export default program;

View File

@@ -276,7 +276,7 @@ class Config {
try {
fs.mkdirSync(userLogsPath, {recursive: true, mode: 0o750});
} catch (e: any) {
log.error("Unable to create logs directory", e);
log.warn("Unable to create logs directory, logging to disk may not work");
}
} else if (logsStat && logsStat.mode & 0o001) {
log.warn(