Browse Source

image and video attachments

develop
Rob Colbert 1 year ago
parent
commit
a9a68b4207
  1. 32
      app/controllers/chat.js
  2. 9
      app/controllers/image.js
  3. 123
      app/controllers/video.js
  4. 4
      app/models/image.js
  5. 67
      app/services/chat.js
  6. 12
      app/services/host-cache.js
  7. 22
      app/services/image.js
  8. 35
      app/services/video.js
  9. 19
      app/views/chat/components/message.pug
  10. 14
      app/views/chat/room/view.pug
  11. 37
      app/workers/host-services.js
  12. 2
      client/css/dtp-site.less
  13. 40
      client/css/site/stage.less
  14. 1
      client/css/site/uikit-theme.dtp-dark.less
  15. 25
      client/css/site/uikit-theme.dtp-light.less
  16. 4
      client/css/site/uk-lightbox.less
  17. 15
      config/limiter.js
  18. 1
      nodemon.json

32
app/controllers/chat.js

@ -44,7 +44,7 @@ export default class ChatController extends SiteController {
router.post(
'/room/:roomId/message',
multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFile', maxCount: 1 }]),
multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFiles', maxCount: 1 }]),
this.postRoomMessage.bind(this),
);
@ -77,6 +77,11 @@ export default class ChatController extends SiteController {
this.getRoomView.bind(this),
);
router.delete(
'/room/:roomId',
this.deleteRoom.bind(this),
);
return router;
}
@ -96,7 +101,19 @@ export default class ChatController extends SiteController {
async postRoomMessage (req, res) {
const { chat: chatService } = this.dtp.services;
try {
await chatService.sendRoomMessage(res.locals.room, req.user, req.body, req.imageFiles, req.videoFiles);
this.log.debug('post attachments', {
imageFiles: req.files.imageFiles,
videoFiles: req.files.videoFiles,
});
await chatService.sendRoomMessage(
res.locals.room,
req.user,
req.body,
req.files.imageFiles,
req.files.videoFiles,
);
return res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to send chat room message', { error });
@ -159,4 +176,15 @@ export default class ChatController extends SiteController {
return next(error);
}
}
async deleteRoom (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
await chatService.destroyRoom(req.user, res.locals.room);
res.redirect('/');
} catch (error) {
this.log.error('failed to destroy chat room', { error });
return next(error);
}
}
}

9
app/controllers/image.js

@ -22,6 +22,7 @@ export default class ImageController extends SiteController {
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const limiterConfig = limiterService.config.image;
const router = express.Router();
dtp.app.use('/image', router);
@ -40,24 +41,24 @@ export default class ImageController extends SiteController {
router.param('imageId', this.populateImage.bind(this));
router.post('/',
limiterService.create(limiterService.config.image.postCreateImage),
limiterService.create(limiterConfig.postCreateImage),
imageUpload.single('file'),
this.postCreateImage.bind(this),
);
router.get('/proxy',
limiterService.create(limiterService.config.image.getProxyImage),
limiterService.create(limiterConfig.getProxyImage),
this.getProxyImage.bind(this),
);
router.get('/:imageId',
limiterService.create(limiterService.config.image.getImage),
limiterService.create(limiterConfig.getImage),
this.getHostCacheImage.bind(this),
// this.getImage.bind(this),
);
router.delete('/:imageId',
limiterService.create(limiterService.config.image.deleteImage),
limiterService.create(limiterConfig.deleteImage),
this.deleteImage.bind(this),
);
}

123
app/controllers/video.js

@ -0,0 +1,123 @@
// video.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import express from 'express';
import { pipeline } from 'node:stream';
import { SiteController, SiteError } from '../../lib/site-lib.js';
export default class VideoController extends SiteController {
static get name ( ) { return 'VideoController'; }
static get slug ( ) { return 'video'; }
constructor (dtp) {
super(dtp, VideoController);
}
async start ( ) {
const {
limiter: limiterService,
session: sessionService,
} = this.dtp.services;
const limiterConfig = limiterService.config.video;
this.templates = {
passwordResetComplete: this.loadViewTemplate('auth/password-reset-complete.pug'),
};
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
this.dtp.app.use('/video', authRequired, router);
router.use(async (req, res, next) => {
res.locals.currentView = 'video';
return next();
});
router.param('videoId', this.populateVideoId.bind(this));
router.get(
'/:videoId/media',
limiterService.create(limiterConfig.getVideoMedia),
this.getVideoMedia.bind(this),
);
return router;
}
async populateVideoId (req, res, next, videoId) {
const { video: videoService } = this.dtp.services;
try {
res.locals.video = await videoService.getVideoById(videoId);
if (!res.locals.video) {
throw new SiteError(404, 'Video not found');
}
return next();
} catch (error) {
this.log.error('failed to populate video', { videoId, error });
return next(error);
}
}
async getVideoMedia (req, res, next) {
const { minio: minioService } = this.dtp.services;
try {
let artifact = res.locals.video.media;
const artifactStat = await minioService.statObject(artifact.bucket, artifact.key);
const fileInfo = Object.assign({ }, artifact);
const range = req.get('range');
if (range) {
let [start, end] = range.replace(/bytes=/, '').split('-').map((term) => parseInt(term.trim(), 10));
if (!isNaN(start) && isNaN(end)) {
end = artifactStat.size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = artifactStat.size - end;
end = artifactStat.size - 1;
}
fileInfo.range = { start, end };
}
this.log.debug('starting video media stream', {
media: artifact,
fileInfo,
});
const stream = await minioService.openDownloadStream(fileInfo);
if (fileInfo.range) {
res.writeHead(206, {
'Content-Type': 'video/mp4',
'Content-Length': fileInfo.range.end - fileInfo.range.start + 1,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${fileInfo.range.start}-${fileInfo.range.end}/${artifactStat.size}`,
'Cache-Control': 'public, maxage=86400, s-maxage=86400, immutable',
});
} else {
res.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Type': 'video/mp4',
'Content-Length': artifactStat.size,
'Cache-Control': 'public, maxage=86400, s-maxage=86400, immutable',
});
}
pipeline(stream, res, (err) => {
if (err) {
this.log.debug('failed to stream media', { err });
return;
}
this.log.info('media stream sent');
});
} catch (error) {
this.log.error('failed to open media stream', { videoId: res.locals.video._id, error });
return next(error);
}
}
}

4
app/models/image.js

@ -11,10 +11,6 @@ const ImageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
caption: { type: String, maxLength: 300 },
flags: {
isSensitive: { type: Boolean, default: false, required: true },
isPendingAttachment: { type: Boolean, default: false, required: true },
},
type: { type: String, required: true },
size: { type: Number, required: true },
file: {

67
app/services/chat.js

@ -7,7 +7,7 @@
import mongoose from 'mongoose';
const ChatRoom = mongoose.model('ChatRoom');
const ChatMessage = mongoose.model('ChatMessage');
// const ChatRoomInvite = mongoose.model('ChatRoomInvite');
const ChatRoomInvite = mongoose.model('ChatRoomInvite');
import numeral from 'numeral';
@ -56,7 +56,12 @@ export default class ChatService extends SiteService {
{
path: 'links',
populate: linkService.populateLink,
},
{
path: 'attachments.images',
},
{
path: 'attachments.videos',
},
];
}
@ -82,9 +87,10 @@ export default class ChatService extends SiteService {
}
async destroyRoom (user, room) {
if (user._id.equals(room.owner._id)) {
if (!user._id.equals(room.owner._id)) {
throw new SiteError(401, 'This is not your chat room');
}
await this.removeInvitesForRoom(room);
await this.removeMessagesForChannel(room);
await ChatRoom.deleteOne({ _id: room._id });
}
@ -209,8 +215,13 @@ export default class ChatService extends SiteService {
}
async sendRoomMessage (room, author, messageDefinition, imageFiles, videoFiles) {
const { text: textService, user: userService } = this.dtp.services;
const NOW = new Date();
const {
image: imageService,
text: textService,
user: userService,
video: videoService,
} = this.dtp.services;
const message = new ChatMessage();
message.created = NOW;
@ -223,6 +234,20 @@ export default class ChatService extends SiteService {
message.hashtags = await textService.findHashtags(message.content);
message.links = await textService.findLinks(author, message.content, { channelId: room._id });
if (imageFiles) {
for (const imageFile of imageFiles) {
const image = await imageService.create(author, { }, imageFile);
message.attachments.images.push(image._id);
}
}
if (videoFiles) {
for (const videoFile of videoFiles) {
const video = await videoService.createVideo(author, { }, videoFile);
message.attachments.videos.push(video._id);
}
}
await message.save();
const messageObj = message.toObject();
@ -324,7 +349,39 @@ export default class ChatService extends SiteService {
return rooms;
}
async removeInvitesForRoom (room) {
await ChatRoomInvite.deleteMany({ room: room._id });
}
async removeMessagesForChannel (channel) {
await ChatMessage.deleteMany({ channel: channel._id });
this.log.alert('removing all messages for channel', { channelId: channel._id });
await ChatMessage
.find({ channel: channel._id })
.cursor()
.eachAsync(async (message) => {
await this.removeMessage(message);
}, 4);
}
async expireMessages ( ) {
const NOW = new Date();
await ChatMessage
.find({ expires: { $lt: NOW } })
.cursor()
.eachAsync(async (message) => {
await this.removeMessage(message);
}, 4);
}
async removeMessage (message) {
const { image: imageService } = this.dtp.services;
if (message.attachments) {
if (Array.isArray(message.attachments.images) && (message.attachments.images.length > 0)) {
for (const image of message.attachments.images) {
await imageService.deleteImage(image);
}
}
}
await ChatMessage.deleteOne({ _id: message._id });
}
}

12
app/services/host-cache.js

@ -133,11 +133,13 @@ export default class HostCacheService extends SiteService {
if (!this.transactions) {
return;
}
for (const key of this.transactions) {
this.log.alert('destroying host cache transaction', { key });
const transaction = this.transactions[key];
transaction.reject(error);
delete this.transactions[key];
if (this.transactions) {
for (const key in this.transactions) {
this.log.alert('destroying host cache transaction', { key });
const transaction = this.transactions[key];
transaction.reject(error);
delete this.transactions[key];
}
}
}
}

22
app/services/image.js

@ -8,7 +8,7 @@ import path from 'node:path';
import fs from 'node:fs';
import mongoose from 'mongoose';
const StreamRayImage = mongoose.model('Image');
const ChatImage = mongoose.model('Image');
import sharp from 'sharp';
@ -39,7 +39,7 @@ export default class ImageService extends SiteService {
async create (owner, imageDefinition, file) {
const NOW = new Date();
const { chat: chatService, minio: minioService } = this.dtp.services;
const { minio: minioService } = this.dtp.services;
this.log.debug('processing uploaded image', { imageDefinition, file });
@ -48,15 +48,9 @@ export default class ImageService extends SiteService {
// create an Image model instance, but leave it here in application memory.
// we don't persist it to the db until MinIO accepts the binary data.
const image = new StreamRayImage();
const image = new ChatImage();
image.created = NOW;
image.owner = owner._id;
if (imageDefinition.caption) {
image.caption = chatService.filterText(imageDefinition.caption);
}
image.flags.isSensitive = imageDefinition['flags.isSensitive'] === 'on';
image.flags.isPendingAttachment = imageDefinition['flags.isPendingAttachment'] === 'on';
image.type = file.mimetype;
image.size = file.size;
@ -90,14 +84,14 @@ export default class ImageService extends SiteService {
}
async getImageById (imageId) {
const image = await StreamRayImage
const image = await ChatImage
.findById(imageId)
.populate(this.populateImage);
return image;
}
async getRecentImagesForOwner (owner) {
const images = await StreamRayImage
const images = await ChatImage
.find({ owner: owner._id })
.sort({ created: -1 })
.limit(10)
@ -117,7 +111,7 @@ export default class ImageService extends SiteService {
this.log.error('failed to remove image from storage', { error });
// fall through
}
await StreamRayImage.deleteOne({ _id: image._id });
await ChatImage.deleteOne({ _id: image._id });
}
async processImageFile (owner, file, outputs, options) {
@ -145,7 +139,7 @@ export default class ImageService extends SiteService {
service.log.debug('processing image', { output, outputMetadata });
const image = new StreamRayImage();
const image = new ChatImage();
image.created = NOW;
image.owner = owner._id;
image.type = `image/${output.format}`;
@ -235,7 +229,7 @@ export default class ImageService extends SiteService {
}
async reportStats ( ) {
const chart = await StreamRayImage.aggregate([
const chart = await ChatImage.aggregate([
{
$match: { },
},

35
app/services/video.js

@ -4,12 +4,14 @@
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import mongoose from 'mongoose';
const Video = mongoose.model('Video');
import mime from 'mime';
import numeral from 'numeral';
import { SiteService, SiteError } from '../../lib/site-lib.js';
import user from '../models/user.js';
@ -23,6 +25,23 @@ export default class VideoService extends SiteService {
super(dtp, VideoService);
}
async start ( ) {
const { user: userService } = this.dtp.services;
await super.start();
await fs.promises.mkdir(process.env.VIDEO_WORK_PATH, { recursive: true });
this.populateVideo = [
{
path: 'owner',
select: userService.USER_SELECT,
},
{
path: 'thumbnail',
},
];
}
async createVideo (owner, attachmentDefinition, file) {
const NOW = new Date();
const { media: mediaService, minio: minioService } = this.dtp.services;
@ -89,16 +108,18 @@ export default class VideoService extends SiteService {
await video.save();
await this.extractVideoAttachmentThumbnail(video, file);
return video.toObject();
}
async extractVideoAttachmentThumbnail (video, file) {
const { media: mediaService } = module.services;
const { media: mediaService } = this.dtp.services;
const thumbnailFile = path.join(process.env.VIDEO_WORK_PATH, `${video._id}.png`);
const ffmpegThumbnailArgs = [
'-y', '-i', file.path,
'-ss', numeral(video.media.metadata.duration * 0.1).format('hh:mm:ss'),
'-ss', numeral(video.media.metadata.duration * 0.05).format('hh:mm:ss'),
'-frames:v', '1',
thumbnailFile,
];
@ -113,7 +134,7 @@ export default class VideoService extends SiteService {
async setVideoThumbnailImage (video, thumbnailFile) {
const { image: imageService } = this.dtp.services;
await this.removeThumbnailImage(video);
await this.removeVideoThumbnailImage(video);
const imageFileDesc = { path: thumbnailFile };
const outputs = [
@ -155,4 +176,12 @@ export default class VideoService extends SiteService {
async setVideoStatus (video, status) {
await Video.updateOne({ _id: video._id }, { $set: { status } });
}
async getVideoById (videoId) {
const video = await Video
.findOne({ _id: videoId })
.populate(this.populateVideo)
.lean();
return video;
}
}

19
app/views/chat/components/message.pug

@ -22,7 +22,24 @@ mixin renderChatMessage (message)
if message.content && (message.content.length > 0)
.message-content
div!= marked.parse(message.content, { renderer: fullMarkedRenderer })
if message.attachments
.message-attachments
if Array.isArray(message.attachments.images) && (message.attachments.images.length > 0)
div(class="uk-child-width-1-1 uk-child-width-1-2@s uk-child-width-1-3@m uk-child-width-1-4@l uk-child-width-1-5@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small
each image of message.attachments.images
a(href=`/image/${image._id}`, data-type="image", data-caption= `${image.metadata.width}x${image.metadata.height} | ${image.metadata.space.toUpperCase()} | ${image.metadata.format.toUpperCase()} | ${numeral(image.size).format('0,0.0b')}`)
img(src=`/image/${image._id}`, width= image.metadata.width, height= image.metadata.height, alt="Image attachment").image-attachment
if Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0)
each video of message.attachments.videos
video(
data-video-id= video._id,
poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false,
controls, disablepictureinpicture, disableremoteplayback, playsinline,
).video-attachment
source(src=`/video/${video._id}/media`)
if Array.isArray(message.links) && (message.links.length > 0)
each link in message.links
div(class="uk-width-large").uk-margin-small

14
app/views/chat/room/view.pug

@ -81,20 +81,22 @@ block view-content
enctype="multipart/form-data"
).uk-form
textarea(id="chat-input-text", name="content", rows=2).uk-textarea.uk-resize-none.uk-border-rounded
.uk-margin-small
.input-button-bar
.uk-flex
.uk-width-expand
div(uk-grid).uk-grid-small
.uk-width-auto
.uk-form-custom
input(id="image-files", name="imageFiles[]", type="file")
button(type="button").uk-button.uk-button-default Image
input(id="image-files", name="imageFiles", type="file")
button(type="button").uk-button.uk-button-default.uk-button-small.uk-border-rounded
i.fa.fa-image
.uk-width-auto
.uk-form-custom
input(id="video-file", name="videoFile", type="file")
button(type="button").uk-button.uk-button-default Video
input(id="video-file", name="videoFiles", type="file")
button(type="button").uk-button.uk-button-default.uk-button-small.uk-border-rounded
i.fa.fa-video
.uk-width-auto
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-primary.uk-button-small.uk-border-rounded.uk-light
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-primary.uk-button-small.uk-border-rounded
i.fas.fa-paper-plane
block viewjs

37
app/workers/host-services.js

@ -154,7 +154,10 @@ class HostCacheTransaction {
return this.dtp.manager.resolveTransaction(this, res);
} catch (error) {
if (error.code !== 'ENOENT') {
this.dtp.log.error('failed to stat requested object', { transaction: this, error });
this.dtp.log.error('failed to stat requested object', {
transaction: this.tid,
error,
});
res.success = false;
res.statusCode = 500;
res.message = error.message;
@ -182,7 +185,10 @@ class HostCacheTransaction {
return this.dtp.manager.resolveTransaction(this, res);
} catch (error) {
if (error.code !== 'NotFound') {
this.dtp.log.error('failed to fetch requested object from MinIO', { transaction: this, error });
this.dtp.log.error('failed to fetch requested object from MinIO', {
transaction: this.tid,
error,
});
res.success = false;
res.statusCode = 500;
res.message = error.message;
@ -217,6 +223,12 @@ class HostCacheTransaction {
},
};
if (this.params.url.startsWith('//')) {
this.dtp.log.debug('correcting image URL', { original: this.params.url });
this.params.url = `https:${this.params.url}`;
this.dtp.log.debug('correcting image URL', { corrected: this.params.url });
}
const urlHash = cryptoService.createHash(this.params.url, 'sha256');
const basePath = path.join(process.env.HOST_CACHE_PATH, 'web-resource', urlHash.slice(0, 4));
await fs.promises.mkdir(basePath, { recursive: true });
@ -240,7 +252,10 @@ class HostCacheTransaction {
return this.dtp.manager.resolveTransaction(this, res);
} catch (error) {
if (error.code !== 'ENOENT') {
this.dtp.log.error('failed to stat requested object', { transaction: this, error });
this.dtp.log.error('failed to stat requested object', {
transaction: this.tid,
error,
});
res.success = false;
res.statusCode = 500;
res.message = error.message;
@ -262,7 +277,10 @@ class HostCacheTransaction {
if (!response.ok) {
this.error = new Error('Failed to fetch URL');
this.flags.isError = true;
this.dtp.log.error(this.error.message, { transaction: this, status: response.status });
this.dtp.log.error(this.error.message, {
transaction: this.tid,
status: response.status,
});
res.success = false;
res.statusCode = response.status;
@ -280,10 +298,10 @@ class HostCacheTransaction {
contentSize = parseInt(contentSize, 10);
}
this.dtp.log.debug('writing initial meta file', { resourceMetaFilename });
this.dtp.log.debug('writing initial meta file', { url: this.params.url, resourceMetaFilename });
await fs.promises.writeFile(resourceMetaFilename, JSON.stringify({ contentType, contentSize }));
this.dtp.log.info('writing web resource file', resourceFilename);
this.dtp.log.info('writing web resource file', { url: this.params.url, resourceFilename });
let writeStream = fs.createWriteStream(resourceFilename, {
autoClose: true,
encoding: 'binary',
@ -300,7 +318,7 @@ class HostCacheTransaction {
contentSize = res.file.stats.size;
res.file.meta = { contentType, contentSize };
this.dtp.log.debug('writing meta file', { resourceMetaFilename });
this.dtp.log.debug('writing meta file', { url: this.params.url, resourceMetaFilename });
await fs.promises.writeFile(resourceMetaFilename, JSON.stringify(res.file.meta));
this.flags.isFetched = true;
@ -314,7 +332,10 @@ class HostCacheTransaction {
} catch (error) {
this.error = error;
this.flags.isError = true;
this.dtp.log.error(this.error.message, { transaction: this, error });
this.dtp.log.error(this.error.message, {
transaction: this.tid,
error,
});
res.success = false;
res.statusCode = error.statusCode || 500;

2
client/css/dtp-site.less

@ -1,3 +1,5 @@
@import "site/uk-lightbox.less";
@import "site/main.less";
@import "site/button.less";
@import "site/navbar.less";

40
client/css/site/stage.less

@ -25,7 +25,6 @@
background-color: @chat-sidebar-bgcolor;
color: @chat-sidebar-color;
border-right: solid 1px @stage-border-color;
overflow-y: auto;
overflow-x: hidden;
@ -164,7 +163,7 @@
.message-timestamp {
font-size: 0.9em;
line-height: 1em;
color: @chat-message-timestamp-color;
color: @system-message-timestamp-color;
}
}
@ -202,6 +201,23 @@
}
}
}
.message-attachments {
img.image-attachment {
border-radius: 8px;
}
video.video-attachment {
width: auto;
height: 240px;
border-radius: 8px;
.controls .progress {
display: none;
}
}
}
}
}
}
@ -217,6 +233,26 @@
background-color: @chat-input-panel-bgcolor;
color: @chat-input-panel-color;
.uk-button.uk-button-default {
border: none;
outline: none;
background-color: aliceblue;
color: #071E22;
}
.uk-button.uk-button-primary {
border: none;
outline: none;
background-color: blanchedalmond;
color: #071E22;
}
.input-button-bar {
margin-top: 5px;
}
}
}

1
client/css/site/uikit-theme.dtp-dark.less

@ -145,6 +145,7 @@ a.uk-button.uk-button-default {
@system-message-bgcolor: #3a3a3a;
@system-message-color: #a8a8a8;
@system-message-timestamp-color: #a8a8a8;
@chat-input-panel-bgcolor: #1a1a1a;
@chat-input-panel-color: #e8e8e8;

25
client/css/site/uikit-theme.dtp-light.less

@ -43,29 +43,30 @@
@stage-border-color: #686868;
@stage-header-bgcolor: #585858;
@stage-header-color: #e5e6b9;
@stage-header-bgcolor: #071E22;
@stage-header-color: #FCF1E8;
@stage-live-member-bgcolor: #1a1a1a;
@stage-live-member-color: #8a8a8a;
@chat-sidebar-bgcolor: #3f5768;
@chat-sidebar-color: #e8e8e8;
@chat-sidebar-bgcolor: #7e7b62;
@chat-sidebar-color: #FCF1E8;
@chat-container-bgcolor: #ffffff;
@chat-container-color: #2a2a2a;
@chat-container-bgcolor: #e8e8e8;
@chat-container-color: #071E22;
@chat-media-bgcolor: #4a4a4a;
@chat-media-color: #e8e8e8;
@chat-message-bgcolor: #e8e8e8;
@chat-message-color: #1a1a1a;
@chat-message-timestamp-color: #a8a8a8;
@chat-message-bgcolor: #FCF1E8;
@chat-message-color: #071E22;
@chat-message-timestamp-color: #679289;
@system-message-bgcolor: #686868;
@system-message-color: #c8c8c8;
@system-message-bgcolor: #EE2E31;
@system-message-color: #FCF1E8;
@system-message-timestamp-color: #FCF1E8;
@chat-input-panel-bgcolor: #e8e8e8;
@chat-input-panel-bgcolor: #a5a17c;
@chat-input-panel-color: #1a1a1a;
@link-container-bgcolor: rgba(0, 0, 0, 0.1);

4
client/css/site/uk-lightbox.less

@ -0,0 +1,4 @@
.uk-lightbox-toolbar {
background-color: rgba(0,0,0, 0.85);
border-top: solid 1px #6a6a6a;
}

15
config/limiter.js

@ -109,8 +109,8 @@ export default {
message: 'You are uploading images too quickly',
},
getProxyImage: {
total: 500,
expire: ONE_SECOND * 10,
total: 50,
expire: ONE_SECOND * 6,
message: 'You are requesting proxy images too quickly',
},
getImage: {
@ -207,6 +207,17 @@ export default {
},
},
/*
* VideoController
*/
video: {
getVideoMedia: {
total: 60,
expire: ONE_HOUR,
message: 'You are loading videos too quickly',
},
},
/*
* WelcomeController
*/

1
nodemon.json

@ -5,6 +5,7 @@
"dist",
"client/**/*",
"lib/client/**/*",
"app/workers/**/*",
"node_modules/**/*"
]
}
Loading…
Cancel
Save