45 changed files with 1538 additions and 74 deletions
@ -1,23 +1,85 @@ |
|||
# DTP Chat |
|||
|
|||
A no-nonsense/no-frills communications platform. |
|||
|
|||
## Production Host Configuration |
|||
## System Requirements |
|||
|
|||
Make sure you've got the latest/greatest for your Ubuntu. |
|||
|
|||
```sh |
|||
apt -y update && apt -y upgrade |
|||
apt -y install python3-pip build-essential ffmpeg supervisor |
|||
``` |
|||
adduser dtp |
|||
The Linux headers and image installs are optional. If you not using a GPU for video encoding, you won't need kernel sources to build your GPU's driver. |
|||
|
|||
```sh |
|||
apt -y install linux-headers-generic linux-headers-virtual linux-image-virtual linux-virtual |
|||
``` |
|||
|
|||
The latest pnpm instructions can always be found here: |
|||
[pnpm installation](https://pnpm.io/installation) |
|||
|
|||
These are just convenience copies from that page. |
|||
|
|||
```sh |
|||
corepack enable pnpm |
|||
corepack use pnpm@latest |
|||
``` |
|||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash |
|||
```sh |
|||
pnpm install |
|||
``` |
|||
|
|||
### Install Yarn v2 |
|||
## Development Host Management |
|||
|
|||
For convenience, it's possible to open multiple terminals in VS Code using `Ctrl + Shift + ~` |
|||
|
|||
In one terminal, start the workers and MinIO. |
|||
|
|||
```sh |
|||
./start-local |
|||
``` |
|||
corepack enable |
|||
yarn set version stable |
|||
yarn install |
|||
|
|||
In a separate terminal, starts the dev application environment. |
|||
|
|||
```sh |
|||
pnpm dev |
|||
``` |
|||
|
|||
## Production Host Configuration |
|||
|
|||
Configure the firewall: |
|||
|
|||
```sh |
|||
ufw allow ssh |
|||
ufw allow http |
|||
ufw allow https |
|||
ufw allow from [console_ip] proto tcp to [host_ip] port 8190 |
|||
ufw enable |
|||
``` |
|||
Create the DTP admin user on the host. All production hosts require this user. Do not set a login password for this user. We will be creating SSH keys for the user to access your git repo as a deployer, but you will never log into the host using the DTP user account. |
|||
|
|||
Instead, we always log in as root, and use `su - dtp` to become the DTP user. |
|||
|
|||
```sh |
|||
adduser dtp # just accept the defaults or enter whatever you want |
|||
su - dtp # this will put you in the DTP home directory as the DTP user |
|||
ssh-keygen # generate the user's SSH key to use for git deployments |
|||
|
|||
# print the DTP user's SSH public key to provide to git repo as deploy key |
|||
cat ~/.ssh/id_rsa.pub |
|||
``` |
|||
Add that SSH key to your git repo as a deploy key. |
|||
|
|||
In the DTP user's home directory: |
|||
|
|||
```sh |
|||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash |
|||
|
|||
npm install --lts |
|||
``` |
|||
|
|||
Edit `.bashrc` and set `NODE_ENV=production` |
|||
|
|||
## Emoji Picker |
|||
|
|||
Chat currently uses [emoji-picker-element](https://www.npmjs.com/package/emoji-picker-element) as the renderer of an emoji picker, and then it manages the presentation of the picker itself. |
@ -0,0 +1,66 @@ |
|||
// admin.js
|
|||
// Copyright (C) 2024 Digital Telepresence, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
import { fileURLToPath } from 'url'; |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
import mongoose from 'mongoose'; |
|||
const User = mongoose.model('User'); |
|||
const ChatRoom = mongoose.model('ChatRoom'); |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
const ChatImage = mongoose.model('Image'); |
|||
const Video = mongoose.model('Video'); |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class AdminController extends SiteController { |
|||
|
|||
static get name ( ) { return 'AdminController'; } |
|||
static get slug ( ) { return 'admin'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, AdminController); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const router = express.Router(); |
|||
this.dtp.app.use('/admin', router); |
|||
|
|||
router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user.js'))); |
|||
|
|||
router.get( |
|||
'/', |
|||
this.getDashboard.bind(this), |
|||
); |
|||
|
|||
return router; |
|||
} |
|||
|
|||
async getDashboard (req, res, next) { |
|||
const { user: userService } = this.dtp.services; |
|||
try { |
|||
res.locals.currentView = 'admin'; |
|||
|
|||
res.locals.stats = { |
|||
userCount: await User.estimatedDocumentCount(), |
|||
chatRoomCount: await ChatRoom.estimatedDocumentCount(), |
|||
chatMessageCount: await ChatMessage.estimatedDocumentCount(), |
|||
imageCount: await ChatImage.estimatedDocumentCount(), |
|||
videoCount: await Video.estimatedDocumentCount(), |
|||
}; |
|||
|
|||
res.locals.latestSignups = await userService.getLatestSignups(10); |
|||
|
|||
res.render('admin/dashboard'); |
|||
} catch (error) { |
|||
this.error.log('failed to present the admin dashboard', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,70 @@ |
|||
// admin/user.js
|
|||
// Copyright (C) 2024 Digital Telepresence, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../../lib/site-lib.js'; |
|||
|
|||
export default class UserAdminController extends SiteController { |
|||
|
|||
static get name ( ) { return 'UserAdminController'; } |
|||
static get slug ( ) { return 'admin'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, UserAdminController); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const router = express.Router(); |
|||
|
|||
router.param('userId', this.populateUserId.bind(this)); |
|||
|
|||
router.get( |
|||
'/:userId', |
|||
this.getUserView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/', |
|||
this.getDashboard.bind(this), |
|||
); |
|||
|
|||
return router; |
|||
} |
|||
|
|||
async populateUserId (req, res, next, userId) { |
|||
const { user: userService } = this.dtp.services; |
|||
try { |
|||
res.locals.userAccount = await userService.getUserAccount(userId); |
|||
if (!res.locals.userAccount) { |
|||
throw new SiteError(404, 'User not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate user account', { userId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getUserView (req, res) { |
|||
res.render('admin/user/view'); |
|||
} |
|||
|
|||
async getDashboard (req, res, next) { |
|||
const { user: userService } = this.dtp.services; |
|||
try { |
|||
res.locals.currentView = 'admin'; |
|||
res.locals.adminView = 'user'; |
|||
|
|||
res.locals.latestSignups = await userService.getLatestSignups(10); |
|||
|
|||
res.render('admin/user/dashboard'); |
|||
} catch (error) { |
|||
this.error.log('failed to present the admin dashboard', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,25 @@ |
|||
extends layout/main |
|||
block admin-content |
|||
|
|||
include user/components/list-table |
|||
|
|||
mixin renderStatsBlock (label, value) |
|||
.stats-block.uk-text-center |
|||
.stat-label= label |
|||
.stat-value= value |
|||
|
|||
.uk-margin-medium |
|||
div(uk-grid) |
|||
.uk-width-1-5 |
|||
+renderStatsBlock('Users', formatCount(stats.userCount)) |
|||
.uk-width-1-5 |
|||
+renderStatsBlock('Chat Rooms', formatCount(stats.chatRoomCount)) |
|||
.uk-width-1-5 |
|||
+renderStatsBlock('Chat Messages', formatCount(stats.chatMessageCount)) |
|||
.uk-width-1-5 |
|||
+renderStatsBlock('Images', formatCount(stats.imageCount)) |
|||
.uk-width-1-5 |
|||
+renderStatsBlock('Videos', formatCount(stats.videoCount)) |
|||
|
|||
h2.uk-margin-small Latest Signups |
|||
+renderAdminUserTable(latestSignups) |
@ -0,0 +1,28 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
.uk-margin |
|||
.uk-container.uk-container-expand |
|||
div(uk-grid) |
|||
div(style="width: 150px;") |
|||
ul.uk-nav.uk-nav-default |
|||
li.uk-nav-header Admin Menu |
|||
li |
|||
a(href="/admin").uk-display-block |
|||
.uk-flex |
|||
.uk-width-auto |
|||
.menu-icon |
|||
i.fa-solid.fa-home |
|||
.uk-width-expand |
|||
span Home |
|||
li |
|||
a(href="/admin/user").uk-display-block |
|||
.uk-flex |
|||
.uk-width-auto |
|||
.menu-icon |
|||
i.fa-solid.fa-user |
|||
.uk-width-expand |
|||
span Users |
|||
|
|||
.uk-width-expand |
|||
block admin-content |
@ -0,0 +1,19 @@ |
|||
mixin renderAdminUserTable (users) |
|||
.uk-overflow-auto |
|||
table.uk-table.uk-table-small.uk-table-justify |
|||
thead |
|||
tr |
|||
th Username |
|||
th Display Name |
|||
th Email |
|||
th(uk-tooltip="(A)dmin (M)oderator (E)mailVerify (G)Cloaked Can(L)ogin Can(C)hat CanC(O)mment Can(R)eport ") Flags |
|||
th Created |
|||
tbody |
|||
each user in users |
|||
tr |
|||
td |
|||
a(href=`/admin/user/${user._id}`)= user.username |
|||
td= user.displayName || '---' |
|||
td= user.email |
|||
td.uk-text-fixed= getUserFlags(user) |
|||
td= dayjs(user.created).fromNow() |
@ -0,0 +1,7 @@ |
|||
extends ../layout/main |
|||
block admin-content |
|||
|
|||
include components/list-table |
|||
|
|||
h1 Latest Signups |
|||
+renderAdminUserTable(latestSignups) |
@ -0,0 +1,103 @@ |
|||
extends ../layout/main |
|||
block admin-content |
|||
|
|||
include ../../user/components/profile-picture |
|||
|
|||
.uk-card.uk-card-default.uk-card-small.uk-border-rounded |
|||
.uk-card-header |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfilePicture(userAccount) |
|||
|
|||
.uk-width-expand |
|||
.uk-margin |
|||
.uk-text-lead= user.displayName |
|||
div(uk-grid).uk-grid-small.uk-grid-divider |
|||
.uk-width-auto @#{user.username} |
|||
.uk-width-auto= user.email |
|||
.uk-width-auto Created #{dayjs(userAccount.created).fromNow()} on #{dayjs(userAccount.created).format('MMMM D, YYYY')} |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="profile-bio").uk-form-label bio |
|||
#profile-bio.markdown-block!= marked.parse(userAccount.bio || '(no bio provided)', { renderer: fullMarkdownRenderer }) |
|||
|
|||
.uk-margin |
|||
label(for="profile-badges").uk-form-label Profile Badges |
|||
input(id="profile-badges", type= "text", placeholder= "Comma-separated list of badge names", value= userAccount.badges.join(',')).uk-input |
|||
|
|||
.uk-margin |
|||
form(method="POST", action=`/admin/user/${userAccount._id}`).uk-form |
|||
.uk-margin |
|||
label.uk-form-label Flags |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="isAdmin", checked= userAccount.flags.isAdmin) |
|||
.state.p-success |
|||
label Admin |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="isModerator", checked= userAccount.flags.isModerator) |
|||
.state.p-success |
|||
label Moderator |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="isEmailVerified", checked= userAccount.flags.isEmailVerified) |
|||
.state.p-success |
|||
label Email Verified |
|||
|
|||
.uk-margin |
|||
label.uk-form-label Permissions |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="canLogin", checked= userAccount.permissions.canLogin) |
|||
.state.p-success |
|||
label Can Login |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="canChat", checked= userAccount.permissions.canChat) |
|||
.state.p-success |
|||
label Can Chat |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="canReport", checked= userAccount.permissions.canReport) |
|||
.state.p-success |
|||
label Can Report |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="canShareLinks", checked= userAccount.permissions.canShareLinks) |
|||
.state.p-success |
|||
label Can Share Links |
|||
|
|||
.uk-margin |
|||
label.uk-form-label Email Opt-In |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="optIn.system", checked= userAccount.optIn.system) |
|||
.state.p-success |
|||
label System Messages |
|||
.uk-width-auto |
|||
.pretty.p-default |
|||
input(type="checkbox", name="optIn.marketing", checked= userAccount.optIn.marketing) |
|||
.state.p-success |
|||
label Marketing |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid).uk-grid-medium |
|||
.uk-width-auto |
|||
button(type="submit", name="action", value="update").uk-button.uk-button-primary.uk-border-rounded |
|||
span |
|||
i.fa-solid.fa-save |
|||
span.uk-margin-small-left Update User |
|||
|
|||
.uk-width-auto |
|||
button(type="submit", name="action", value="ban").uk-button.uk-button-danger.uk-border-rounded |
|||
span |
|||
i.fa-solid.fa-cancel |
|||
span.uk-margin-small-left Ban User |
@ -0,0 +1,3 @@ |
|||
include ../../components/library |
|||
include reaction-bar |
|||
+renderReactionBar(message) |
@ -0,0 +1,18 @@ |
|||
mixin renderReactionBar (message) |
|||
.message-reaction-bar |
|||
if Array.isArray(message.reactions) && (message.reactions.length > 0) |
|||
.uk-flex.uk-flex-middle |
|||
each reaction of message.reactions |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji= reaction.emoji, |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-react-button |
|||
.uk-flex.uk-flex-middle |
|||
span.reaction-emoji= reaction.emoji |
|||
span= formatCount(reaction.users.length) |
|||
div(uk-dropdown="mode: hover") |
|||
span.uk-margin-small-right= reaction.emoji |
|||
span= reaction.users.map((user) => user.username).join(',') |
@ -1,8 +1,27 @@ |
|||
//- Common routines for all views |
|||
- |
|||
function formatCount (count) { |
|||
return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0'); |
|||
} |
|||
|
|||
function getUserPictureUrl (userProfile, which) { |
|||
if (!userProfile || !userProfile.picture || !userProfile.picture[which]) { |
|||
return `https://${site.domain}/img/default-member.png`; |
|||
} |
|||
return `https://${site.domain}/image/${userProfile.picture[which]._id}`; |
|||
} |
|||
|
|||
function getUserFlags (user) { |
|||
const fA = `${user.flags.isAdmin ? 'A' : '-'}`; |
|||
const fM = `${user.flags.isModerator ? 'M' : '-'}`; |
|||
const fE = `${user.flags.isEmailVerified ? 'E' : '-'}`; |
|||
const fG = `${user.flags.isCloaked ? 'G' : '-'}`; |
|||
|
|||
const pL = `${user.permissions.canLogin ? 'L' : '-'}`; |
|||
const pC = `${user.permissions.canChat ? 'C' : '-'}`; |
|||
const pO = `${user.permissions.canComment ? 'O' : '-'}`; |
|||
const pR = `${user.permissions.canReport ? 'R' : '-'}`; |
|||
const pI = `${user.permissions.canShareLinks ? 'I' : '-'}`; |
|||
|
|||
return `${fA}${fM}${fE}${fG} ${pL}${pC}${pO}${pR}${pI}`; |
|||
} |
@ -0,0 +1,70 @@ |
|||
// chat-processor.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
|
|||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|||
|
|||
import { CronJob } from 'cron'; |
|||
const CRON_TIMEZONE = 'America/New_York'; |
|||
|
|||
class ChatProcessorService extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'ChatProcessorService'; } |
|||
static get slug ( ) { return 'chatProcessor'; } |
|||
|
|||
constructor (rootPath) { |
|||
super(ChatProcessorService, rootPath); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const mongoose = await import('mongoose'); |
|||
this.ChatMessage = mongoose.model('ChatMessage'); |
|||
|
|||
/* |
|||
* Cron jobs |
|||
*/ |
|||
|
|||
const messageExpireSchedule = '0 0 * * * *'; // Every hour
|
|||
this.cronJob = new CronJob( |
|||
messageExpireSchedule, |
|||
this.expireChatMessages.bind(this), |
|||
null, |
|||
true, |
|||
CRON_TIMEZONE, |
|||
); |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
this.log.alert('ChatLinksWorker shutting down'); |
|||
await super.shutdown(); |
|||
} |
|||
|
|||
async expireChatMessages ( ) { |
|||
const { chat: chatService } = this.services; |
|||
await chatService.expireMessages(); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
try { |
|||
const { fileURLToPath } = await import('node:url'); |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
const worker = new ChatProcessorService(path.resolve(__dirname, '..', '..')); |
|||
await worker.start(); |
|||
|
|||
} catch (error) { |
|||
console.error('failed to start chat processing worker', { error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
@ -0,0 +1,84 @@ |
|||
// worker-template.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
|
|||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|||
|
|||
import { CronJob } from 'cron'; |
|||
const CRON_TIMEZONE = 'America/New_York'; |
|||
|
|||
class TemplateService extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'TemplateService'; } |
|||
static get slug ( ) { return 'template'; } |
|||
|
|||
constructor (rootPath) { |
|||
super(TemplateService, rootPath); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const mongoose = await import('mongoose'); |
|||
this.Link = mongoose.model('Link'); |
|||
|
|||
this.viewModel = { }; |
|||
await this.populateViewModel(this.viewModel); |
|||
|
|||
/* |
|||
* Cron jobs |
|||
*/ |
|||
|
|||
const cronJobSchedule = '*/5 * * * * *'; // Every 5 seconds
|
|||
this.cronJob = new CronJob( |
|||
cronJobSchedule, |
|||
this.cronJobProcessor.bind(this), |
|||
null, |
|||
true, |
|||
CRON_TIMEZONE, |
|||
); |
|||
|
|||
/* |
|||
* Bull Queue job processors |
|||
*/ |
|||
|
|||
this.log.info('registering queue job processor', { config: this.config.jobQueues.links }); |
|||
this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links); |
|||
this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this)); |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
this.log.alert('ChatLinksWorker shutting down'); |
|||
await super.shutdown(); |
|||
} |
|||
|
|||
async cronJobProcessor ( ) { |
|||
this.log.info('your cron job is running now'); |
|||
} |
|||
|
|||
async ingestLink (job) { |
|||
this.log.info('processing queue job', { id: job.id }); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
try { |
|||
const { fileURLToPath } = await import('node:url'); |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
const worker = new TemplateService(path.resolve(__dirname, '..', '..')); |
|||
await worker.start(); |
|||
|
|||
} catch (error) { |
|||
console.error('failed to start template worker', { error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
@ -1,9 +1,13 @@ |
|||
@import "site/uk-lightbox.less"; |
|||
|
|||
@import "site/main.less"; |
|||
|
|||
@import "site/button.less"; |
|||
@import "site/drop-feedback.less"; |
|||
@import "site/emoji-picker.less"; |
|||
@import "site/image.less"; |
|||
@import "site/link-preview.less"; |
|||
@import "site/menu.less"; |
|||
@import "site/navbar.less"; |
|||
@import "site/stage.less"; |
|||
@import "site/stage.less"; |
|||
@import "site/stats.less"; |
@ -0,0 +1,50 @@ |
|||
.dtp-drop-feedback { |
|||
position: fixed; |
|||
top: 0; right: 0; bottom: 0; left: 0; |
|||
box-sizing: border-box; |
|||
|
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
background-color: rgba(0,0,0, 0.8); |
|||
color: #e8e8e8; |
|||
|
|||
opacity: 0; |
|||
transition: opacity 0.2s; |
|||
|
|||
pointer-events: none; |
|||
|
|||
&.feedback-active { |
|||
opacity: 1; |
|||
} |
|||
|
|||
.drop-feedback-container { |
|||
box-sizing: border-box; |
|||
padding: 20px; |
|||
|
|||
border: dotted 3px #e8e8e8; |
|||
border-radius: 10px; |
|||
|
|||
background-color: #48a8d4; |
|||
color: #e8e8e8; |
|||
|
|||
pointer-events: none; |
|||
|
|||
.feedback-icon { |
|||
margin-bottom: 10px; |
|||
|
|||
font-size: 2.5em; |
|||
line-height: 1; |
|||
|
|||
color: #ffffff; |
|||
|
|||
pointer-events: none; |
|||
} |
|||
|
|||
.drop-feedback-prompt { |
|||
font-size: 1.2em; |
|||
pointer-events: none; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
.menu-icon { |
|||
width: 2em; |
|||
text-align: center; |
|||
} |
@ -0,0 +1,18 @@ |
|||
.stats-block { |
|||
padding: 10px 20px; |
|||
|
|||
border: solid 1px red; |
|||
border-radius: 10px; |
|||
|
|||
background-color: #fff0f0; |
|||
color: #2a2a2a; |
|||
|
|||
.stat-label { |
|||
font-size: 0.8em; |
|||
} |
|||
|
|||
.stat-value { |
|||
font-size: 1.2em; |
|||
font-weight: bold; |
|||
} |
|||
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,157 @@ |
|||
// dtp-chat-cli.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import { dirname } from 'path'; |
|||
import fs from 'fs'; |
|||
|
|||
import { fileURLToPath } from 'url'; |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
import mongoose from 'mongoose'; |
|||
|
|||
import { SiteRuntime } from './lib/site-runtime.js'; |
|||
|
|||
class SiteTerminalApp extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'SiteTerminalApp'; } |
|||
static get slug ( ) { return 'cliApp'; } |
|||
|
|||
constructor ( ) { |
|||
super(SiteTerminalApp, __dirname); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.processors = { |
|||
'grant': this.grant.bind(this), |
|||
'revoke': this.revoke.bind(this), |
|||
'probe': this.probeMediaFile.bind(this), |
|||
'transcodeMov': this.transcodeMov.bind(this), |
|||
'transcodeGif': this.transcodeGif.bind(this), |
|||
}; |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
await super.shutdown(); |
|||
return 0; // exitCode
|
|||
} |
|||
|
|||
async run (args) { |
|||
this.log.debug('running', { args }); |
|||
const command = args.shift(); |
|||
const processor = this.processors[command]; |
|||
if (!processor) { |
|||
this.log.error('Unknown command', { command }); |
|||
return; |
|||
} |
|||
return processor(args); |
|||
} |
|||
|
|||
async grant (args) { |
|||
const User = mongoose.model('User'); |
|||
|
|||
const privilege = args.shift(); |
|||
const username = args.shift(); |
|||
|
|||
const user = await User.findOne({ username_lc: username.toLowerCase().trim() }).select('_id'); |
|||
if (!user) { |
|||
throw new Error('User not found'); |
|||
} |
|||
|
|||
switch (privilege) { |
|||
case 'admin': |
|||
this.log.info('granting admin privileges'); |
|||
await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': true } }); |
|||
break; |
|||
case 'moderator': |
|||
this.log.info('granting moderator privileges'); |
|||
await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': true } }); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
async revoke (args) { |
|||
const User = mongoose.model('User'); |
|||
|
|||
const username_lc = args.shift(); |
|||
const user = await User.findOne({ username_lc }).select('_id'); |
|||
if (!user) { |
|||
throw new Error('User not found'); |
|||
} |
|||
|
|||
const privilege = args.shift(); |
|||
switch (privilege) { |
|||
case 'admin': |
|||
this.log.info('revoking admin privileges'); |
|||
await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': false } }); |
|||
break; |
|||
case 'moderator': |
|||
this.log.info('revoking moderator privileges'); |
|||
await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': false } }); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
async probeMediaFile (args) { |
|||
const { media: mediaService } = this.services; |
|||
const filename = args.shift(); |
|||
const probe = await mediaService.ffprobe(filename); |
|||
this.log.info('FFPROBE result', { probe }); |
|||
} |
|||
|
|||
async transcodeMov (args) { |
|||
const { video: videoService } = this.services; |
|||
|
|||
const filename = args.shift(); |
|||
const stat = await fs.promises.stat(filename); |
|||
const file = { |
|||
path: filename, |
|||
size: stat.size, |
|||
type: 'video/quicktime', |
|||
}; |
|||
|
|||
await videoService.transcodeMov(file); |
|||
|
|||
this.log.info('transcode output ready', { filename: file.path }); |
|||
} |
|||
|
|||
async transcodeGif (args) { |
|||
const { video: videoService } = this.services; |
|||
|
|||
const filename = args.shift(); |
|||
const stat = await fs.promises.stat(filename); |
|||
const file = { |
|||
path: filename, |
|||
size: stat.size, |
|||
type: 'image/gif', |
|||
}; |
|||
|
|||
await videoService.transcodeGif(file); |
|||
|
|||
this.log.info('transcode output ready', { filename: file.path }); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
let app; |
|||
try { |
|||
app = new SiteTerminalApp(); |
|||
await app.start(); |
|||
await app.run(process.argv.slice(2)); |
|||
} catch (error) { |
|||
console.error('failed to start Chat terminal application', error); |
|||
} finally { |
|||
await app.shutdown(); |
|||
process.nextTick(( ) => { |
|||
process.exit(0); |
|||
}); |
|||
} |
|||
|
|||
})(); |
Loading…
Reference in new issue