major update

This commit is contained in:
Troplo 2022-07-29 19:04:37 +10:00
parent be90a91e52
commit f17a9d3027
37 changed files with 3510 additions and 1987 deletions

2
NOTICE
View File

@ -1,2 +1,2 @@
Colubrina or Troplo/Colubrina is a simple chatting platform that allows you to communicate with people over websockets.
Licensed under GNU GPLv3, and based off of the original BetterCompass Communications by @Troplo.
Licensed under GNU GPLv3.

View File

@ -1,6 +1,13 @@
# Colubrina
![Wakatime](https://wakatime.troplo.com/api/badge/Troplo/interval:any/project:Colubrina?label=wakatime)
<img src="https://i.troplo.com/i/d608273e066c.png" alt="Chat" width="45%"></img>
<img src="https://i.troplo.com/i/e8e2c9d6e349.png" alt="Friends" width="45%"></img>
<img src="https://i.troplo.com/i/e958b8e58c5e.png" alt="Chat with AMOLED theme" width="45%"></img>
<img src="https://i.troplo.com/i/279376da3f1d.png" alt="Chat with profile card and light theme" width="45%"></img>
<img src="https://i.troplo.com/i/59b63d5aa167.png" alt="QuickSwitcher" width="45%"></img>
<img src="https://i.troplo.com/i/b2d6dd14c6b6.png" alt="QuickSwitcher with AMOLED theme" width="45%"></img>
## Project setup
Rename .env.example to .env and fill it out with your own information.

View File

@ -1,6 +1,7 @@
HOSTNAME=localhost
RELEASE=dev
CORS_HOSTNAME=http://localhost:8080
RELEASE=stable
NOTIFICATION=
WEATHER_API_KEY=OpenWeatherMap API Key
PARENT_LINK=[{"intendedFor": [0], "instance": "compass-vic", "userId": 1, "token": "aaaaaaaa-bbbbbbbb-cccccccc-dddddddd", "whitelistedSenderIds": []}]
NOTIFICATION_TYPE=info
NOTIFICATION_TYPE=info
SITE_NAME=Colubrina
ALLOW_REGISTRATIONS=true

View File

@ -1,4 +1,2 @@
BetterCompass is created and maintained by GitHub user @Troplo, and Gitea user https://git.troplo.com/Troplo, and is licensed under the GNU GENERAL PUBLIC LICENSE Version 3, which can be found in the LICENSE file at the root of this project.
The source code is available on both Gitea, and GitHub respectively, https://git.troplo.com/Troplo/compass-vue, and https://github.com/Troplo/BetterCompass
BetterCompass Proxy is provided with the BetterCompass software, and has the same license.
Colubrina is created and maintained by GitHub user @Troplo, and Gitea user https://git.troplo.com/Troplo, and is licensed under the GNU GENERAL PUBLIC LICENSE Version 3, which can be found in the LICENSE file at the root of this project.
The source code is available on GitHub, https://github.com/Troplo/Colubrina

322
backend/cli/index.js Normal file
View File

@ -0,0 +1,322 @@
const input = require("input")
const fs = require("fs")
const path = require("path")
const { Umzug, SequelizeStorage } = require("umzug")
const { Sequelize } = require("sequelize")
const argon2 = require("argon2")
const { production: config } = require("../config/config.json")
const { User } = require("../models")
const axios = require("axios")
const os = require("os")
console.log("Troplo/Colubrina CLI")
console.log("Colubrina version", require("../../package.json").version)
async function checkForUpdates() {
await axios
.get("https://services.troplo.com/api/v1/state", {
headers: {
"X-Troplo-Project": "colubrina"
},
timeout: 800
})
.then((res) => {
if (require("../../package.json").version !== res.data.latestVersion) {
console.log("A new version of Colubrina is available!")
console.log("Latest version:", res.data.latestVersion)
} else {
console.log("Colubrina is up to date.")
}
})
.catch(() => {
console.log(
"Failed to check for updates, ensure you are connected to the internet, and services.troplo.com is whitelisted behind any potential firewalls."
)
})
}
let state = {
db: {
host: "localhost",
port: 3306,
username: "colubrina",
password: null,
database: "colubrina",
storage: "./storage.db"
},
dbConfig: {}
}
async function doSetupDB() {
const dialect = await input.select(
"What database dialect do you want to use? (MariaDB tested, recommended)",
["mariadb", "postgres", "sqlite"]
)
const host = await input.text("What is the host?", {
default: state.db.host || "localhost"
})
const port = await input.text("What is the port?", {
default: state.db.port || 3306
})
const username = await input.text("What is the username?", {
default: state.db.username || "colubrina"
})
const password = await input.text("What is the password?", {
default: state.db.password ? "Enter for cached password" : "Please specify"
})
const database = await input.text("What is the database name?", {
default: state.db.database || "colubrina"
})
let storage
if (dialect === "sqlite") {
storage = await input.text(
"What is the path to the storage file (SQLite only)?",
{
default: state.db.storage || "./storage.db"
}
)
}
state.db = {
username: username,
password: password,
database: database,
host: host,
dialect: dialect,
port: port,
logging: false
}
state.dbConfig = {
development: {
username: username,
password: password,
database: database,
host: host,
dialect: dialect,
port: port,
storage: dialect === "sqlite" ? storage : null,
logging: false
},
test: {
username: username,
password: password,
database: database,
host: host,
dialect: dialect,
port: port,
logging: false
},
production: {
username: username,
password: password,
database: database,
host: host,
dialect: dialect,
port: port,
logging: false
}
}
await testDB()
}
async function testDB() {
try {
const sequelize = new Sequelize(state.db)
await sequelize.authenticate()
console.log("Connection to database has been established successfully.")
} catch (error) {
console.error("Unable to connect to the database:", error)
await doSetupDB()
}
}
async function dbSetup() {
await doSetupDB()
fs.writeFileSync(
path.join(__dirname, "../config/config.json"),
JSON.stringify(state.dbConfig)
)
console.log("config/config.json overwritten")
}
async function runMigrations() {
console.log("Running migrations")
const config = require("../config/config.json").production
const sequelize = new Sequelize(config)
const umzug = new Umzug({
migrations: { glob: "../migrations/*.js" },
context: sequelize.getQueryInterface(),
storage: new SequelizeStorage({ sequelize }),
logger: console,
logging: true
})
await (async () => {
await umzug.up()
})()
console.log("Migrations applied")
}
async function createUser() {
const user = {
username: await input.text("Username", {
default: "admin"
}),
password: await argon2.hash(await input.text("Password", {})),
email: await input.text("Email", {
default: "troplo@troplo.com"
}),
admin: JSON.parse(
await input.confirm("Admin (true/false)", {
default: false
})
)
}
await User.create(user)
console.log("User created")
}
async function configureDotEnv() {
function setEnvValue(key, value) {
const ENV_VARS = fs.readFileSync("../.env", "utf8").split(os.EOL)
// find the env we want based on the key
const target = ENV_VARS.indexOf(
ENV_VARS.find((line) => {
// (?<!#\s*) Negative lookbehind to avoid matching comments (lines that starts with #).
// There is a double slash in the RegExp constructor to escape it.
// (?==) Positive lookahead to check if there is an equal sign right after the key.
// This is to prevent matching keys prefixed with the key of the env var to update.
const keyValRegex = new RegExp(`(?<!#\\s*)${key}(?==)`)
return line.match(keyValRegex)
})
)
// if key-value pair exists in the .env file,
if (target !== -1) {
// replace the key/value with the new value
ENV_VARS.splice(target, 1, `${key}=${value}`)
} else {
// if it doesn't exist, add it instead
ENV_VARS.push(`${key}=${value}`)
}
// write everything back to the file system
fs.writeFileSync("../.env", ENV_VARS.join(os.EOL))
}
if (!fs.existsSync("../.env")) {
fs.writeFileSync("../.env", "")
}
setEnvValue(
"HOSTNAME",
await input.text("Public Domain", {
default: "localhost"
})
)
setEnvValue(
"CORS_HOSTNAME",
await input.text("Public Hostname", {
default: "http://localhost:8080"
})
)
setEnvValue(
"SITE_NAME",
await input.text("Site Name", {
default: "Colubrina"
})
)
setEnvValue(
"ALLOW_REGISTRATIONS",
await input.text("Permit Public Registrations", {
default: false
})
)
setEnvValue("NOTIFICATION", "")
setEnvValue("NOTIFICATION_TYPE", "info")
setEnvValue("RELEASE", "stable")
}
async function init() {
while (true) {
const option = await input.select(`Please select an option`, [
"Setup",
"Create user",
"Run migrations",
"Update/create config file",
"Check for updates",
"Exit"
])
if (option === "Setup") {
if (fs.existsSync(path.join(__dirname, "../.env"))) {
const option = await input.confirm(".env already exists, overwrite?", {
default: false
})
if (option) {
await configureDotEnv()
}
} else {
await configureDotEnv()
}
if (fs.existsSync(path.join(__dirname, "../config/config.json"))) {
const option = await input.select(
`config/config.json already exists. Do you want to overwrite it?`,
["Yes", "No"]
)
if (option === "Yes") {
await dbSetup()
}
} else {
await dbSetup()
}
await runMigrations()
const { User, Theme } = require("../models")
try {
await Theme.bulkCreate(
JSON.parse(
fs.readFileSync(path.join(__dirname, "./templates/themes.json"))
)
)
} catch {
console.log("Themes already exist.")
}
try {
await User.create({
username: "Colubrina",
id: 0,
bot: true,
email: "colubrina@troplo.com"
})
await User.update(
{
id: 0
},
{
where: {
username: "Colubrina"
}
}
)
} catch (e) {
console.log(e)
console.log("Users already exist.")
}
console.log("DB templates applied")
console.log("Admin user creation")
await createUser()
console.log("Colubrina has been setup.")
console.log(
"Colubrina can be started with `yarn serve` or `node .` in the backend directory."
)
console.log(
"The Colubrina frontend can be built with `yarn build` in the root project directory, and is recommended to be served via NGINX, with a proxy_pass to the backend on /api and /socket.io."
)
} else if (option === "Update/create config file") {
await dbSetup()
console.log("config/config.json overwritten or created")
} else if (option === "Create user") {
await createUser()
} else if (option === "Run migrations") {
await runMigrations()
} else if (option === "Check for updates") {
await checkForUpdates()
} else if (option === "Exit") {
process.exit(0)
}
}
}
checkForUpdates().finally(() => {
init()
})

View File

@ -0,0 +1,133 @@
[
{
"id": 1,
"name": "Colubrina Classic",
"userId": 0,
"public": true,
"theme": {
"id": 1,
"name": "Colubrina Classic",
"primaryType": "all",
"dark": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#151515",
"toolbar": "#191919",
"sheet": "#181818",
"text": "#000000",
"dark": "#151515",
"bg": "#151515"
},
"light": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#f8f8f8",
"toolbar": "#f8f8f8",
"sheet": "#f8f8f8",
"text": "#000000",
"dark": "#f8f8f8",
"bg": "#f8f8f8"
}
},
"createdAt": "2022-03-26 23:23:29",
"updatedAt": "2022-03-26 23:23:29"
},
{
"id": 2,
"name": "Colubrina Gray",
"userId": 0,
"public": true,
"theme": {
"id": 2,
"name": "Colubrina Gray",
"primaryType": "all",
"user": {
"sussiId": "asdasd"
},
"dark": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#262626",
"toolbar": "#262626",
"sheet": "#262626",
"text": "#000000",
"dark": "#262626",
"bg": "#191919"
},
"light": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#dedede",
"toolbar": "#dedede",
"sheet": "#dedede",
"text": "#000000",
"dark": "#dedede",
"bg": "#e7e7e7"
}
},
"createdAt": "2022-03-26 23:25:12",
"updatedAt": "2022-03-26 23:25:12"
},
{
"id": 3,
"name": "Colubrina AMOLED (Black)",
"userId": 0,
"public": true,
"theme": {
"id": 3,
"name": "Colubrina AMOLED",
"primaryType": "dark",
"dark": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#000000",
"toolbar": "#121212",
"sheet": "#000000",
"text": "#000000",
"dark": "#000000",
"bg": "#000000"
},
"light": {
"primary": "#0190ea",
"secondary": "#757575",
"accent": "#000000",
"error": "#ff1744",
"info": "#2196F3",
"success": "#4CAF50",
"warning": "#ff9800",
"card": "#f8f8f8",
"toolbar": "#f8f8f8",
"sheet": "#f8f8f8",
"text": "#000000",
"dark": "#f8f8f8"
}
},
"createdAt": "2022-03-26 23:25:12",
"updatedAt": "2022-03-26 23:25:12"
}
]

View File

@ -0,0 +1,9 @@
[{
"username": "Colubrina",
"id": 0,
"password": null,
"email": null,
"storedStatus": "invisible",
"status": "offline",
"bot": true
}]

View File

@ -27,7 +27,8 @@ app.get("/api/v1/state", async (req, res) => {
notification: process.env.NOTIFICATION,
notificationType: process.env.NOTIFICATION_TYPE,
latestVersion: require("../package.json").version,
name: "Colubrina"
name: process.env.SITE_NAME,
allowRegistrations: JSON.parse(process.env.ALLOW_REGISTRATIONS)
})
})

View File

@ -2,25 +2,16 @@ let Errors = {
unknown: ["Something went wrong.", 500],
unauthorized: ["You don't have permission to do that.", 401],
notAuthenticated: ["You have to login to do that", 401],
userNotOptedIn: [
"You have to opt in to BetterCompass Accounts to do that.",
401
],
invalidUserOrPassword: ["Invalid username or password.", 401],
parentLinkIneligible: ["Your school does not support ParentLink.", 401],
invalidTotp: ["Invalid 2FA code.", 401],
invalidCredentials: ["Invalid username or password.", 401],
bcSessionsForced: [
"You are attempting to login as a user that enforces BetterCompass Sessions.\nBetterCompass extended functionality is disabled for this session instance.",
401
],
rateLimit: [
"You are being rate-limited. Please try again in a few minutes.",
429
],
communicationsUserNotFound: ["This user does not exist.", 400],
communicationsUserNotOptedIn: [
"This user has not opted in to BetterCompass Communications.",
"This user does not have chatting enabled.",
400
],
friendAlreadyFriends: ["You are already friends with this user.", 400],
@ -39,20 +30,20 @@ let Errors = {
400
],
fileTooLarge: ["The file you are trying to upload is too large.", 400],
unauthorizedInstance: [
"You are not authorized to access BetterCompass.",
401
],
invalidPassword: [
"Your password must be at least 8 characters in length.",
400
],
registrationsDisabled: [
"Registrations are currently disabled on this instance. Please try again later.",
400
]
}
function processErrors(errorName) {
let arr = Errors[errorName]
temp = {}
let temp = {}
temp.name = errorName
temp.message = arr[0]
temp.status = arr[1]

View File

@ -4,7 +4,7 @@ module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Users", "font", {
type: Sequelize.STRING,
defaultValue: "Roboto",
defaultValue: "Inter",
allowNull: false
})
},

View File

@ -24,6 +24,11 @@ module.exports = (sequelize, DataTypes) => {
}
User.init(
{
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true
},
username: {
type: DataTypes.STRING,
allowNull: false,
@ -112,7 +117,7 @@ module.exports = (sequelize, DataTypes) => {
},
font: {
type: DataTypes.STRING,
defaultValue: "Roboto",
defaultValue: "Inter",
allowNull: false
},
status: {
@ -144,6 +149,10 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
lastSeenAt: {
type: DataTypes.DATE,
allowNull: true
}
},
{

View File

@ -8,8 +8,8 @@
"serve": "nodemon"
},
"dependencies": {
"argon2": "^0.28.5",
"axios": "^0.26.1",
"argon2": "^0.28.7",
"axios": "^0.27.2",
"clean-css": "^4.1.11",
"constantinople": "3.1.1",
"cookie-parser": "^1.4.6",
@ -20,20 +20,26 @@
"express-rate-limit": "^6.4.0",
"file-type": "16.5.3",
"http-errors": "~1.6.3",
"input": "^1.0.1",
"jade": "~1.11.0",
"jw-paginate": "^1.0.4",
"local-cors-proxy": "^1.1.0",
"mariadb": "^3.0.0",
"mariadb": "^3.0.1",
"multer": "^1.4.4",
"node-xwhois": "^2.0.10",
"open-graph-scraper": "^4.11.0",
"patch-package": "^6.4.7",
"pg": "^8.7.3",
"pg-hstore": "^2.3.4",
"semver": "^7.3.7",
"sequelize": "^6.17.0",
"sequelize": "^6.21.3",
"sequelize-cli": "^6.4.1",
"socket.io": "^4.5.1",
"speakeasy": "^2.0.0",
"sqlite3": "^5.0.10",
"ua-parser-js": "^1.0.2",
"uglify-js": "^2.6.0"
"uglify-js": "^2.6.0",
"umzug": "^3.1.1"
},
"resolutions": {
"constantinople": "^3.1.1",

View File

@ -2,7 +2,7 @@ const express = require("express")
const router = express.Router()
const Errors = require("../lib/errors.js")
const auth = require("../lib/authorize.js")
const { User, Theme, Feedback, Message } = require("../models")
const { User, Theme, Message } = require("../models")
const { Op } = require("sequelize")
const dayjs = require("dayjs")
const fs = require("fs")
@ -25,7 +25,6 @@ router.get("/", auth, async (req, res, next) => {
res.json({
users: await User.count(),
themes: await Theme.count(),
feedback: await Feedback.count(),
messages: await Message.count(),
usersToday: await User.count({
where: {
@ -62,6 +61,14 @@ router.get("/metrics", auth, async (req, res, next) => {
exclude: ["totp", "compassSession", "password"]
}
})
const messages = await Message.findAll({
where: {
createdAt: createdAt
},
attributes: {
exclude: ["totp", "compassSession", "password"]
}
})
const registrationGraphInterim = registrationStats.reduce(function (
result,
@ -76,18 +83,14 @@ router.get("/metrics", auth, async (req, res, next) => {
},
{})
const activeUsersGraphInterim = registrationStats.reduce(function (
result,
user
) {
let day = dayjs(user.lastSeenAt).format("YYYY-MM-DD")
const messagesGraphInterim = messages.reduce(function (result, message) {
let day = dayjs(message.createdAt).format("YYYY-MM-DD")
if (!result[day]) {
result[day] = 0
}
result[day]++
return result
},
{})
}, {})
const usersGraph = {
labels: Object.keys(registrationGraphInterim),
@ -102,12 +105,12 @@ router.get("/metrics", auth, async (req, res, next) => {
]
}
const activeUsersGraph = {
labels: Object.keys(activeUsersGraphInterim),
const messagesGraph = {
labels: Object.keys(messagesGraphInterim),
datasets: [
{
data: Object.values(activeUsersGraphInterim),
label: "Active Users",
data: Object.values(messagesGraphInterim),
label: "Messages",
borderColor: "#3e95cd",
pointBackgroundColor: "#FFFFFF",
backgroundColor: "transparent"
@ -117,7 +120,7 @@ router.get("/metrics", auth, async (req, res, next) => {
res.json({
users: usersGraph,
activeUsers: activeUsersGraph
activeUsers: messagesGraph
})
} catch (err) {
return next(err)
@ -184,23 +187,6 @@ router.put("/themes/apply", auth, async (req, res, next) => {
}
})
router.get("/feedback", auth, async (req, res, next) => {
try {
const feedback = await Feedback.findAndCountAll({
order: [["createdAt", "DESC"]],
include: [
{
model: User,
as: "user"
}
]
})
res.json(feedback)
} catch (err) {
return next(err)
}
})
router.put("/state", auth, async (req, res, next) => {
function setEnvValue(key, value) {
// read file from hdd & split if from a linebreak to a array
@ -233,6 +219,7 @@ router.put("/state", auth, async (req, res, next) => {
}
try {
const io = req.app.get("io")
setEnvValue("ALLOW_REGISTRATIONS", req.body.allowRegistrations)
if (req.body.broadcastType === "permanent") {
setEnvValue("NOTIFICATION", req.body.notification)
setEnvValue("NOTIFICATION_TYPE", req.body.notificationType)
@ -242,7 +229,8 @@ router.put("/state", auth, async (req, res, next) => {
io.emit("siteState", {
notification: req.body.notification,
notificationType: req.body.notificationType,
latestVersion: require("../../package.json").version
latestVersion: require("../../package.json").version,
allowRegistrations: req.body.allowRegistrations
})
res.sendStatus(204)
} catch (err) {

View File

@ -47,7 +47,92 @@ const upload = multer({
const resolveEmbeds = require("../lib/resolveEmbeds.js")
const paginate = require("jw-paginate")
async function createMessage(req, type, content, association, userId) {
const io = req.app.get("io")
const message = await Message.create({
userId: 0,
chatId: association.chatId,
content: content,
type: type || "system"
})
const associations = await ChatAssociation.findAll({
where: {
chatId: association.chatId
}
})
const messageLookup = await Message.findOne({
where: {
id: message.id
},
include: [
{
model: Attachment,
as: "attachments"
},
{
model: Message,
as: "reply",
include: [
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: Chat,
as: "chat",
include: [
{
model: User,
as: "users",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
},
{
model: User,
as: "user",
attributes: [
"username",
"name",
"avatar",
"id",
"createdAt",
"updatedAt"
]
}
]
})
associations.forEach((user) => {
console.log(user)
io.to(user.dataValues.userId).emit("message", {
...messageLookup.dataValues,
associationId: user.dataValues.id,
keyId: `${
message.dataValues.id
}-${message.dataValues.updatedAt.toISOString()}`
})
})
}
router.get("/", auth, async (req, res, next) => {
try {
let chats = await ChatAssociation.findAll({
@ -746,6 +831,8 @@ router.post("/friends", auth, async (req, res, next) => {
friendId: req.user.id,
status: "pendingCanAccept"
})
io.to(user.id).emit("friendUpdate", {})
io.to(req.user.id).emit("friendUpdate", {})
io.to(user.id).emit("friendRequest", {
...remoteFriend.dataValues,
user: {
@ -768,6 +855,7 @@ router.post("/friends", auth, async (req, res, next) => {
router.delete("/friends/:id", auth, async (req, res, next) => {
try {
const io = req.app.get("io")
const friend = await Friend.findOne({
where: {
userId: req.user.id,
@ -782,6 +870,8 @@ router.delete("/friends/:id", auth, async (req, res, next) => {
friendId: req.user.id
}
})
io.to(friend.friendId).emit("friendUpdate", {})
io.to(req.user.id).emit("friendUpdate", {})
res.sendStatus(204)
} else {
throw Errors.friendNotFound
@ -826,9 +916,8 @@ router.put("/friends/:id", auth, async (req, res, next) => {
await remoteFriend.update({
status: "accepted"
})
io.to(req.user.id).emit("friendAccepted", {
...friend.dataValues
})
io.to(friend.userId).emit("friendUpdate", {})
io.to(remoteFriend.userId).emit("friendUpdate", {})
io.to(remoteFriend.userId).emit("friendAccepted", {
...remoteFriend.dataValues
})
@ -1004,6 +1093,13 @@ router.put("/:id", auth, async (req, res, next) => {
name: req.body.name
})
})
await createMessage(
req,
"rename",
`${req.user.username} renamed the chat to ${req.body.name}`,
association,
req.user.id
)
res.sendStatus(204)
} else {
throw Errors.chatNotFoundOrNotAdmin

View File

@ -199,6 +199,9 @@ router.post("/register", async (req, res, next) => {
}
}
try {
if (!JSON.parse(process.env.ALLOW_REGISTRATIONS)) {
throw Errors.registrationsDisabled
}
if (req.body.password.length < 8) {
throw Errors.invalidPassword
}
@ -211,7 +214,7 @@ router.post("/register", async (req, res, next) => {
name: req.body.username,
admin: false,
email: req.body.email,
font: "Roboto",
font: "Inter",
status: "offline",
storedStatus: "online",
experiments: [],
@ -230,9 +233,7 @@ router.post("/register", async (req, res, next) => {
router.get("/", auth, (req, res, next) => {
try {
res.json(req.user)
} catch (e) {
console.log(1)
}
} catch {}
})
router.get("/sessions", auth, async (req, res, next) => {

File diff suppressed because it is too large Load Diff

View File

@ -506,6 +506,108 @@ export default {
}
},
methods: {
registerSocket() {
if (!this.$store.state.wsRegistered) {
this.$store.state.wsRegistered = true
this.$store.dispatch("getCommunicationsUnread")
this.$socket.on("friendAccepted", (message) => {
this.$notification.show(
message.user.username,
{
body:
message.user2.username + " has accepted your friend request",
icon: message.user2.avatar
? "/usercontent/" + message.user2.avatar
: null
},
{}
)
new Audio(require("@/assets/audio/message.wav")).play()
this.$toast.success(
"Friend request accepted by " + message.user2.username
)
})
this.$socket.on("message", (message) => {
this.$store.state.communicationNotifications += 1
this.$store.state.chats.find(
(chat) => chat.id === message.associationId
).unread += 1
if (
(message.userId !== this.$store.state.user.id &&
this.$route.params?.id !== message.associationId &&
this.$store.state.user?.storedStatus !== "busy") ||
(message.userId !== this.$store.state.user.id &&
this.$store.state.user?.storedStatus !== "busy" &&
!document.hasFocus())
) {
if (localStorage.getItem("messageAudio")) {
if (JSON.parse(localStorage.getItem("messageAudio"))) {
new Audio(require("@/assets/audio/message.wav")).play()
}
} else {
new Audio(require("@/assets/audio/message.wav")).play()
}
this.$notification.show(
message.user.username + " (" + message.chat.name + ")",
{
body: message.content,
icon: message.user.avatar
? "/usercontent/" + message.user.avatar
: null
},
{}
)
this.$toast.info(
"Message: " +
message.content +
"\n\n" +
"From: " +
message.user.username +
"\n" +
"Sent in: " +
message.chat.name,
{
onClick: () => {
this.$router.push("/communications/" + message.associationId)
}
}
)
}
})
if (this.$store.state.user.storedStatus !== "busy") {
this.$socket.on("friendRequest", (message) => {
this.$notification.show(
message.user.username,
{
body: message.user.username + " has sent a friend request",
icon: message.user.avatar
? "/usercontent/" + message.user.avatar
: null
},
{}
)
new Audio(require("@/assets/audio/message.wav")).play()
this.$toast.info("Friend request sent by " + message.user.username)
})
}
this.$store.commit("setWSConnected", true)
this.$socket.on("disconnect", () => {
this.$store.commit("setWSConnected", false)
})
this.$socket.on("connect", () => {
this.$store.commit("setWSConnected", true)
})
this.$socket.on("siteState", (state) => {
this.$store.state.site.latestVersion = state.latestVersion
this.$store.state.site.notification = state.notification
this.$store.state.site.notificationType = state.notificationType
})
// eslint-disable-next-line no-undef
this.$store.dispatch("updateQuickSwitch")
} else {
console.info("Socket already registered.")
}
},
communicationsIdleCheck() {
let time
let idle = false
@ -652,103 +754,18 @@ export default {
this.$socket.connect()
document.title = this.$route.name
? this.$route.name + " - " + this.$store.state.site.name
: this.$store.state.site.name
: this.$store.state.site.name || "Colubrina"
this.$store.commit("setLoading", true)
this.$vuetify.theme.dark = this.$store.state.user?.theme === "dark" || true
this.$store.dispatch("getState")
this.$store.dispatch("checkAuth").catch(() => {
this.$store.dispatch("logout")
this.$router.push("/login")
})
this.getThemes()
this.$store
.dispatch("getUserInfo")
.then(() => {
this.communicationsIdleCheck()
this.$store.dispatch("getCommunicationsUnread")
this.$socket.on("message", (message) => {
this.$store.state.communicationNotifications += 1
if (
(this.$route.name !== "Communications" &&
this.$store.state.user.storedStatus !== "busy") ||
(this.$route.name === "Communications" &&
!document.hasFocus() &&
this.$store.state.user.storedStatus !== "busy")
) {
if (localStorage.getItem("messageAudio")) {
if (JSON.parse(localStorage.getItem("messageAudio"))) {
new Audio(require("@/assets/audio/message.wav")).play()
}
} else {
new Audio(require("@/assets/audio/message.wav")).play()
}
this.$notification.show(
message.user.username + " (" + message.chat.name + ")",
{
body: message.content,
icon: message.user.avatar
? "/usercontent/" + message.user.avatar
: null
},
{}
)
this.$toast.info(
"Message: " +
message.content +
"\n\n" +
"From: " +
message.user.username +
"\n" +
"Sent in: " +
message.chat.name,
{
onClick: () => {
this.$router.push("/communications/" + message.associationId)
}
}
)
}
})
if (this.$store.state.user.storedStatus !== "busy") {
this.$socket.on("friendRequest", (message) => {
this.$notification.show(
message.user.username,
{
body: message.user.username + " has sent a friend request",
icon: message.user.avatar
? "/usercontent/" + message.user.avatar
: null
},
{}
)
new Audio(require("@/assets/audio/message.wav")).play()
this.$toast.info("Friend request sent by " + message.user.username)
})
}
this.$store.commit("setWSConnected", true)
this.$socket.on("disconnect", () => {
this.$store.commit("setWSConnected", false)
})
this.$socket.on("connect", () => {
this.$store.commit("setWSConnected", true)
})
this.$socket.on("siteState", (state) => {
this.$store.state.site.latestVersion = state.latestVersion
this.$store.state.site.notification = state.notification
this.$store.state.site.notificationType = state.notificationType
})
// eslint-disable-next-line no-undef
if (JSON.parse(process.env.VUE_APP_MATOMO_ENABLED)) {
// eslint-disable-next-line no-undef
_paq.push(["setUserId", this.$store.state.user.id])
// eslint-disable-next-line no-undef
_paq.push(["trackPageView"])
}
this.$store.dispatch("updateQuickSwitch")
})
.catch(() => {
this.communicationsIdleCheck()
this.$store.dispatch("getUserInfo").catch(() => {
if (!["/login", "/register"].includes(this.$route.path)) {
this.$router.push("/login")
})
}
})
this.registerSocket()
},
watch: {
"$store.state.userPanel"(val) {

View File

@ -0,0 +1 @@
Message audio sound from jitsi-meet project, https://github.com/jitsi/jitsi-meet/blob/master/LICENSE

View File

@ -45,7 +45,7 @@ img.emoji {
}
.troplo-nav {
font-family: "Roboto", sans-serif;
font-family: "Inter", sans-serif;
}
.troplo-header {

View File

@ -165,7 +165,7 @@ export default {
reader.readAsDataURL(file)
},
handleChange() {
if (this.$store.state.user.bcUser.storedStatus !== "invisible") {
if (this.$store.state.user.storedStatus !== "invisible") {
if (this.typingDate) {
const now = new Date()
if (now - this.typingDate > 5000) {

View File

@ -405,10 +405,11 @@
background-color="toolbar"
style="margin-bottom: -18px"
elevation="2"
v-model="search"
></v-text-field>
<v-toolbar color="toolbar" class="rounded-xl mb-3" elevation="2">
<v-toolbar-title class="subtitle-1">
CHATS ({{ $store.state.chats.length }})
CHATS ({{ chats.length }})
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="dialogs.new = true">
@ -416,7 +417,7 @@
</v-btn>
</v-toolbar></template
>
<v-list v-for="item in $store.state.chats" :key="item.id">
<v-list v-for="item in chats" :key="item.id">
<template>
<v-list-item
:to="'/communications/' + item.id"
@ -556,7 +557,7 @@
<v-list-item-content
@click="copyUsername"
v-bind="attrs"
style="cursor: pointer"
style="cursor: pointer; min-width: 100px"
>
<v-list-item-title>
{{ $store.state.user.username }}
@ -569,6 +570,15 @@
<span>Copied!</span>
</v-tooltip>
<v-spacer></v-spacer>
<v-btn
icon
text
to="/admin"
v-if="$store.state.user.admin"
style="margin-right: 0; padding-right: 0"
>
<v-icon>mdi-gavel</v-icon>
</v-btn>
<v-btn icon text to="/settings">
<v-icon>mdi-cog</v-icon>
</v-btn>
@ -589,6 +599,7 @@ export default {
components: { NicknameDialog },
data() {
return {
search: "",
nickname: {
dialog: false,
nickname: "",
@ -644,6 +655,23 @@ export default {
}
}
},
computed: {
chats() {
if (!this.search?.length) {
return this.$store.state.chats
}
return this.$store.state.chats.filter((item) => {
return (
(item.chat.type === "group" &&
item.chat.name.toLowerCase().includes(this.search.toLowerCase())) ||
(item.chat.type === "direct" &&
this.getDirectRecipient(item)
.name.toLowerCase()
.includes(this.search.toLowerCase()))
)
})
}
},
methods: {
groupSettings(id) {
this.settings.item = this.$store.state.chats.find(
@ -875,14 +903,15 @@ export default {
createConversation() {
this.newConversation.loading = true
this.axios
.post("/api/v1/communications/create", {
.post(process.env.VUE_APP_BASE_URL + "/api/v1/communications/create", {
users: this.newConversation.users
})
.then(() => {
.then((res) => {
this.newConversation.name = ""
this.newConversation.users = []
this.newConversation.loading = false
this.newConversation.results = []
this.dialogs.new = false
this.$router.push("/communications/" + res.data.id)
})
.catch((e) => {
this.newConversation.loading = false
@ -927,6 +956,10 @@ export default {
this.searchUsers()
this.searchUsersForGroupAdmin()
this.$store.dispatch("getChats")
this.$socket.on("friendUpdate", () => {
this.searchUsers()
this.searchUsersForGroupAdmin()
})
this.$socket.on("userSettings", () => {
this.$store.dispatch("getChats")
})

View File

@ -0,0 +1,197 @@
<template>
<v-dialog v-model="user.value" max-width="650px">
<v-card v-if="user.item" class="user-popout" height="600px">
<v-toolbar color="toolbar" height="100">
<v-avatar size="64" class="mr-3 mb-2 mt-2">
<v-img
:src="$store.state.baseURL + '/usercontent/' + user.item.avatar"
v-if="user.item.avatar"
class="elevation-1"
/>
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
</v-avatar>
<v-toolbar-title>
{{ getName(user.item) }}
<v-tooltip top v-if="user.item.admin">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-crown </v-icon>
</v-btn>
</template>
<span>Colubrina Instance Administrator</span>
</v-tooltip>
<v-tooltip top v-if="user.item.id < 35">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-alpha-a-circle </v-icon>
</v-btn>
</template>
<span>Early User</span>
</v-tooltip>
<v-tooltip top v-if="user.item.bot">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-robot </v-icon>
</v-btn>
</template>
<span>Bot</span>
</v-tooltip>
<div class="subheading subtitle-1 text--lighten-2">
<template v-if="user.item.nickname"
>{{ user.item.username }}:</template
>{{ user.item.instance }}
</div>
</v-toolbar-title>
</v-toolbar>
<v-tabs :show-arrows="false" fixed-tabs background-color="card">
<v-tab>
<v-icon>mdi-account-multiple</v-icon>&nbsp; Mutual Friends
</v-tab>
<v-tab> <v-icon>mdi-account-group</v-icon>&nbsp; Mutual Groups </v-tab>
<v-tab-item
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-list
:height="400"
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-overlay :value="loading.mutualFriends" absolute>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-list-item
v-for="item in mutualFriends"
:key="item.id"
@click="openUserPanel(item)"
>
<v-list-item-title>
<v-avatar size="24">
<v-img
:src="$store.state.baseURL + '/usercontent/' + item.avatar"
v-if="item.avatar"
class="elevation-1"
/>
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
</v-avatar>
{{ getName(item) }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-tab-item>
<v-tab-item
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-list
:height="400"
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-overlay :value="loading.mutualFriends" absolute>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-list-item
v-for="item in mutualGroups"
:key="item.id"
@click="$router.push('/communications/' + item.associationId)"
>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-tab-item>
</v-tabs>
<v-card-actions
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="user.value = false"> Close </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import AjaxErrorHandler from "@/lib/errorHandler"
export default {
name: "UserDialog",
props: ["user"],
data() {
return {
mutualGroups: [],
mutualFriends: [],
loading: {
mutualFriends: true,
mutualGroups: true
}
}
},
methods: {
getName(user) {
if (user.nickname?.nickname) {
return user.nickname.nickname
} else {
return user.username
}
}
},
mounted() {
this.mutualGroups = []
this.mutualFriends = []
this.loading = {
mutualGroups: true,
mutualFriends: true
}
this.axios
.get(
process.env.VUE_APP_BASE_URL +
"/api/v1/communications/mutual/" +
this.user.item.id +
"/groups"
)
.then((res) => {
this.mutualGroups = res.data
this.loading.mutualGroups = false
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
this.axios
.get(
process.env.VUE_APP_BASE_URL +
"/api/v1/communications/mutual/" +
this.user.item.id +
"/friends"
)
.then((res) => {
this.mutualFriends = res.data
this.loading.mutualFriends = false
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
}
}
</script>
<style scoped></style>

View File

@ -13,9 +13,6 @@ import VueSanitize from "vue-sanitize"
import "@mdi/font/css/materialdesignicons.css"
import "./plugins/dayjs"
import VueApollo from "./plugins/apollo"
import VueMatomo from "vue-matomo"
import * as Sentry from "@sentry/vue"
import { BrowserTracing } from "@sentry/tracing"
import SocketIO from "socket.io-client"
import twemoji from "twemoji"
import VueNativeNotification from "vue-native-notification"
@ -72,25 +69,6 @@ md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
// pass token to default renderer.
return defaultRender(tokens, idx, options, env, self)
}
if (
process.env.NODE_ENV === "production" &&
JSON.parse(process.env.VUE_APP_SENTRY_ENABLED)
) {
Sentry.init({
Vue,
dsn: process.env.VUE_APP_SENTRY_DSN,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ["bettercompass.com.au", "compass.troplo.com", /^\//]
})
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0
})
}
Vue.use(VueNativeNotification, {
requestOnNotify: false
@ -106,100 +84,6 @@ Vue.use({
}
})
if (JSON.parse(process.env.VUE_APP_MATOMO_ENABLED)) {
Vue.use(VueMatomo, {
// Configure your matomo server and site by providing
host: process.env.VUE_APP_MATOMO_URL,
siteId: process.env.VUE_APP_MATOMO_SITE_ID,
// Changes the default .js and .php endpoint's filename
// Default: 'matomo'
trackerFileName: process.env.VUE_APP_MATOMO_TRACKER,
// Overrides the autogenerated tracker endpoint entirely
// Default: undefined
// trackerUrl: 'https://example.com/whatever/endpoint/you/have',
// Overrides the autogenerated tracker script path entirely
// Default: undefined
// trackerScriptUrl: 'https://example.com/whatever/script/path/you/have',
// Enables automatically registering pageviews on the router
router: router,
// Enables link tracking on regular links. Note that this won't
// work for routing links (ie. internal Vue router links)
// Default: true
enableLinkTracking: true,
// Require consent before sending tracking information to matomo
// Default: false
requireConsent: false,
// Whether to track the initial page view
// Default: true
trackInitialView: true,
// Run Matomo without cookies
// Default: false
disableCookies: false,
// Require consent before creating matomo session cookie
// Default: false
requireCookieConsent: false,
// Enable the heartbeat timer (https://developer.matomo.org/guides/tracking-javascript-guide#accurately-measure-the-time-spent-on-each-page)
// Default: false
enableHeartBeatTimer: false,
// Set the heartbeat timer interval
// Default: 15
heartBeatTimerInterval: 15,
// Whether or not to log debug information
// Default: false
debug: false,
// UserID passed to Matomo (see https://developer.matomo.org/guides/tracking-javascript-guide#user-id)
// Default: undefined
userId: undefined,
// Share the tracking cookie across subdomains (see https://developer.matomo.org/guides/tracking-javascript-guide#measuring-domains-andor-sub-domains)
// Default: undefined, example '*.example.com'
cookieDomain: undefined,
// Tell Matomo the website domain so that clicks on these domains are not tracked as 'Outlinks'
// Default: undefined, example: '*.example.com'
domains: process.env.VUE_APP_MATOMO_DOMAINS,
// A list of pre-initialization actions that run before matomo is loaded
// Default: []
// Example: [
// ['API_method_name', parameter_list],
// ['setCustomVariable','1','VisitorType','Member'],
// ['appendToTrackingUrl', 'new_visit=1'],
// etc.
// ]
preInitActions: [],
// A function to determine whether to track an interaction as a site search
// instead of as a page view. If not a function, all interactions will be
// tracked as page views. Receives the new route as an argument, and
// returns either an object of keyword, category (optional) and resultsCount
// (optional) to track as a site search, or a falsey value to track as a page
// view.
// Default: false, i.e. track all interactions as page views
// Example: (to) => {
// if (to.query.q && to.name === 'search') {
// return { keyword: to.query.q, category: to.params.category }
// } else {
// return null
// }
// }
trackSiteSearch: false
})
}
Vue.use(VueSanitize, {
allowedTags: [
"address",

View File

@ -15,7 +15,7 @@ const routes = [
children: [
{
path: "friends",
name: "Friends - Communications",
name: "Friends",
component: () =>
import(
/* webpackChunkName: "communicationsFriends" */ "../views/Communications/CommunicationsFriends"
@ -85,7 +85,7 @@ const routes = [
},
{
path: "communications",
name: "Communications",
name: "Communications Settings",
component: () =>
import(
/* webpackChunkName: "settingsCommunications" */ "../views/Settings/SettingsCommunications"
@ -116,14 +116,6 @@ const routes = [
/* webpackChunkName: "adminUsers" */ "../views/Admin/AdminUsers.vue"
)
},
{
path: "feedback",
name: "Feedback",
component: () =>
import(
/* webpackChunkName: "adminFeedback" */ "../views/Admin/AdminFeedback.vue"
)
},
{
path: "themes",
name: "Themes",
@ -166,4 +158,11 @@ const router = new VueRouter({
routes
})
const originalPush = router.push
router.push = function push(location, onResolve, onReject) {
if (onResolve || onReject)
return originalPush.call(this, location, onResolve, onReject)
return originalPush.call(this, location).catch((err) => err)
}
export default router

View File

@ -60,6 +60,7 @@ export default new Vuex.Store({
},
communicationNotifications: 0,
wsConnected: false,
wsRegistered: false,
lastChat: "friends",
searchPanel: false,
userPanel: false
@ -258,7 +259,7 @@ export default new Vuex.Store({
document.head.appendChild(style)
}
},
checkAuth(context) {
checkAuth() {
return new Promise((resolve, reject) => {
Vue.axios.defaults.headers.common["Authorization"] =
localStorage.getItem("session")
@ -270,7 +271,6 @@ export default new Vuex.Store({
.catch((e) => {
if (e?.response?.status === 401) {
reject(false)
context.dispatch("logout")
} else {
AjaxErrorHandler(this.$store)(e)
}
@ -321,7 +321,7 @@ export default new Vuex.Store({
context.commit("updateQuickSwitchCache", {
subjectLongName: "Home",
customType: 1,
route: "/"
route: "/communications/friends"
})
context.state.chats.forEach((chat) => {
context.commit("updateQuickSwitchCache", {
@ -417,7 +417,6 @@ div {
resolve(res.data)
})
.catch((e) => {
console.log(e)
if (JSON.parse(localStorage.getItem("userCache"))?.id) {
const user = JSON.parse(localStorage.getItem("userCache"))
const name = user.themeObject.id
@ -475,12 +474,7 @@ div {
sheet: "#181818",
text: "#000000",
dark: "#151515",
bg: "#151515",
calendarNormalActivity: "#3f51b5",
calendarActivityType7: "#f44336",
calendarActivityType8: "#4caf50",
calendarActivityType10: "#ff9800",
calendarExternalActivity: "#2196f3"
bg: "#151515"
},
light: {
primary: "#0190ea",
@ -495,12 +489,7 @@ div {
sheet: "#f8f8f8",
text: "#000000",
dark: "#f8f8f8",
bg: "#f8f8f8",
calendarNormalActivity: "#3f51b5",
calendarActivityType7: "#f44336",
calendarActivityType8: "#4caf50",
calendarActivityType10: "#ff9800",
calendarExternalActivity: "#2196f3"
bg: "#f8f8f8"
}
}
const name = theme.id

View File

@ -17,10 +17,6 @@
<v-icon>mdi-account-multiple</v-icon>&nbsp;
<span>Users</span>
</v-tab>
<v-tab to="/admin/feedback">
<v-icon>mdi-bug</v-icon>&nbsp;
<span>Feedback</span>
</v-tab>
<v-tab to="/admin/themes">
<v-icon>mdi-brush</v-icon>&nbsp;
<span>Themes</span>

View File

@ -1,58 +0,0 @@
<template>
<div id="admin-feedback">
<v-toolbar color="toolbar">
<v-toolbar-title>Feedback ({{ feedback.count }})</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn @click="getFeedback" icon>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-toolbar>
<v-container>
<v-card
v-for="item in feedback.rows"
:key="item.id"
class="rounded-xl mb-2"
color="card"
>
<v-toolbar color="toolbar">
<v-toolbar-title>
Feedback -
{{
item.user
? item.user?.displayCode + ` (BCID: ${item.user?.id})`
: item.userId || "Unknown"
}}
</v-toolbar-title>
</v-toolbar>
<v-container>
{{ item.feedbackText.substring(0, 1000) }}
<br />
<small v-if="item.user"> User ID: {{ item.user.id }} </small>
</v-container>
</v-card>
</v-container>
</div>
</template>
<script>
export default {
name: "AdminFeedback",
data() {
return {
feedback: []
}
},
methods: {
getFeedback() {
this.axios.get("/api/v1/admin/feedback").then((res) => {
this.feedback = res.data
})
}
},
mounted() {
this.getFeedback()
}
}
</script>
<style scoped></style>

View File

@ -26,18 +26,6 @@
</v-container>
</v-card>
</v-col>
<v-col>
<v-card color="card" class="rounded-xl">
<v-toolbar color="toolbar">
<v-toolbar-title> Feedback </v-toolbar-title>
</v-toolbar>
<v-container>
<h1 class="text-center" style="font-size: 69px">
{{ admin.feedback }}
</h1>
</v-container>
</v-card>
</v-col>
<v-col>
<v-card color="card" class="rounded-xl">
<v-toolbar color="toolbar">

View File

@ -26,6 +26,12 @@
text-value="value"
>
</v-select>
<v-switch
inset
class="mx-3"
label="Allow registrations"
v-model="allowRegistrations"
></v-switch>
<v-btn text class="mx-3 mb-3" color="primary" @click="updateState"
>Save</v-btn
>
@ -40,6 +46,7 @@ export default {
return {
notification: "",
notificationType: "info",
allowRegistrations: true,
notificationTypes: [
{ text: "Info", value: "info" },
{ text: "Success", value: "success" },
@ -65,7 +72,8 @@ export default {
.put("/api/v1/admin/state", {
notification: this.notification,
notificationType: this.notificationType,
broadcastType: this.broadcastType
broadcastType: this.broadcastType,
allowRegistrations: this.allowRegistrations
})
.then(() => {
this.$toast.success("State updated")
@ -78,6 +86,7 @@ export default {
mounted() {
this.notification = this.$store.state.site.notification
this.notificationType = this.$store.state.site.notificationType
this.allowRegistrations = this.$store.state.site.allowRegistrations
}
}
</script>

View File

@ -41,21 +41,13 @@ export default {
text: "ID",
value: "id"
},
{
text: "Compass User ID",
value: "compassUserId"
},
{
text: "Sussi Auth ID",
value: "sussiId"
},
{
text: "Username",
value: "displayCode"
value: "username"
},
{
text: "Instance",
value: "instance"
text: "Email",
value: "email"
},
{
text: "Created At",
@ -69,10 +61,6 @@ export default {
text: "Base Theme",
value: "theme"
},
{
text: "Communications",
value: "privacy.communications.enabled"
},
{
text: "Theme",
value: "themeObject.name"

View File

@ -33,187 +33,11 @@
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="context.userPopout.value" max-width="650px">
<v-card v-if="context.userPopout.item" class="user-popout" height="600px">
<v-toolbar color="toolbar" height="100">
<v-avatar size="64" class="mr-3 mb-2 mt-2">
<v-img
:src="
$store.state.baseURL +
'/usercontent/' +
context.userPopout.item.avatar
"
v-if="context.userPopout.item.avatar"
class="elevation-1"
/>
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
</v-avatar>
<v-toolbar-title>
{{ getName(context.userPopout.item) }}
<v-tooltip top v-if="context.userPopout.item.admin">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-crown </v-icon>
</v-btn>
</template>
<span>Colubrina Instance Administrator</span>
</v-tooltip>
<v-tooltip top v-if="context.userPopout.item.id < 35">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-alpha-a-circle </v-icon>
</v-btn>
</template>
<span>Early User</span>
</v-tooltip>
<v-tooltip top v-if="context.userPopout.item.bot">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" small>
<v-icon> mdi-robot </v-icon>
</v-btn>
</template>
<span>Bot</span>
</v-tooltip>
<div class="subheading subtitle-1 text--lighten-2">
<template v-if="context.userPopout.item.nickname"
>{{ context.userPopout.item.username }}:</template
>{{ context.userPopout.item.instance }}
</div>
</v-toolbar-title>
</v-toolbar>
<v-tabs :show-arrows="false" fixed-tabs background-color="card">
<v-tab>
<v-icon>mdi-account-multiple</v-icon>&nbsp; Mutual Friends
</v-tab>
<v-tab>
<v-icon>mdi-account-group</v-icon>&nbsp; Mutual Groups
</v-tab>
<v-tab-item
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-list
:height="400"
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light']
.card
"
>
<v-overlay
:value="context.userPopout.item.loading.mutualFriends"
absolute
>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-list-item
v-for="item in context.userPopout.item.mutualFriends"
:key="item.id"
@click="openUserPanel(item)"
>
<v-list-item-title>
<v-avatar size="24">
<v-img
:src="
$store.state.baseURL + '/usercontent/' + item.avatar
"
v-if="item.avatar"
class="elevation-1"
/>
<v-icon v-else class="elevation-1"> mdi-account </v-icon>
</v-avatar>
{{ getName(item) }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-tab-item>
<v-tab-item
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-list
:height="400"
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light']
.card
"
>
<v-overlay
:value="context.userPopout.item.loading.mutualFriends"
absolute
>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-list-item
v-for="item in context.userPopout.item.mutualGroups"
:key="item.id"
@click="$router.push('/communications/' + item.associationId)"
>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-tab-item>
</v-tabs>
<v-card-actions
:style="
'background-color: ' +
$vuetify.theme.themes[$vuetify.theme.dark ? 'dark' : 'light'].card
"
>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="context.userPopout.value = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="nickname.dialog" width="500px">
<v-card>
<v-card-title>
<span class="headline">{{ nickname.user.username }}</span>
</v-card-title>
<v-card-text>
<v-text-field
v-model="nickname.nickname"
label="Nickname"
required
@keyup.enter="setFriendNickname"
autofocus
></v-text-field>
<small>Friend nicknames only show to you.</small>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue darken-1"
text
@click="
nickname.dialog = false
nickname.nickname = ''
nickname.user = {}
"
>
Cancel
</v-btn>
<v-btn color="blue darken-1" text @click="setFriendNickname">
Apply
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<UserDialog
:user="context.userPopout"
:key="context.userPopout.item?.id || 0"
></UserDialog>
<NicknameDialog :nickname="context.nickname" />
<v-dialog
v-model="preview.dialog"
elevation="0"
@ -906,6 +730,128 @@
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'rename'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
<template v-else-if="message.type === 'system'">
<v-list-item :key="message.keyId" :id="'message-' + index">
<v-icon color="grey" class="mr-2 ml-1"> mdi-pencil </v-icon>
<v-list-item-content>
{{ message.content }}
</v-list-item-content>
<v-list-item-action>
<v-list-item-subtitle>
{{
$date(message.createdAt).format("DD/MM/YYYY hh:mm A")
}}
</v-list-item-subtitle>
<v-list-item-subtitle>
<v-btn
icon
v-if="message.userId === $store.state.user.id"
@click="deleteMessage(message)"
>
<v-icon> mdi-delete </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = message.content
edit.editing = true
edit.id = message.id
"
v-if="
message.userId === $store.state.user.id &&
edit.id !== message.id
"
>
<v-icon> mdi-pencil </v-icon>
</v-btn>
<v-btn
icon
@click="
edit.content = ''
edit.editing = false
edit.id = null
"
v-if="
message.userId === $store.state.user.id &&
edit.id === message.id
"
>
<v-icon> mdi-close </v-icon>
</v-btn>
<v-btn
icon
@click="
replying = message
focusInput()
"
>
<v-icon> mdi-reply </v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-action>
</v-list-item>
</template>
</template>
</v-list>
</v-card-text>
@ -965,23 +911,32 @@
</v-card-text>
</v-card>
</v-col>
<v-divider
vertical
style="z-index: 2; padding-right: 3px; padding-left: 3px"
v-if="
($store.state.userPanel && !$vuetify.breakpoint.mobile) ||
($store.state.searchPanel && !$vuetify.breakpoint.mobile)
"
></v-divider>
<v-col
cols="3"
class=""
id="search-col"
v-if="searchPanel && !$vuetify.breakpoint.mobile"
v-if="$store.state.searchPanel && !$vuetify.breakpoint.mobile"
>
<v-card
class="d-flex flex-column fill-height rounded-xl"
class="d-flex flex-column fill-height"
style="overflow: scroll; height: calc(100vh - 24px - 40px - 40px)"
color="card"
elevation="0"
>
<v-toolbar color="transparent" class="flex-grow-0 flex-shrink-0">
<v-toolbar color="toolbar" class="flex-grow-0 flex-shrink-0">
<v-toolbar-title>
Search ({{ search.pager.totalItems || 0 }})
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="searchPanel = false">
<v-btn icon @click="$store.state.searchPanel = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
@ -1330,10 +1285,14 @@
</v-card>
</v-col>
<v-col
cols="3"
:cols="$vuetify.breakpoint.xl ? 2 : 3"
class="ml-2"
id="user-col"
v-if="$store.state.userPanel && !$vuetify.breakpoint.mobile"
v-if="
$store.state.userPanel &&
!$vuetify.breakpoint.mobile &&
!$store.state.searchPanel
"
>
<v-card
class="d-flex flex-column fill-height rounded-xl"
@ -1363,17 +1322,6 @@
</v-list-item>
</v-list>
</v-menu>
<v-toolbar
color="transparent"
class="flex-grow-0 flex-shrink-0"
elevation="2"
>
<v-toolbar-title> Members </v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="$store.state.userPanel = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-list two-line color="card">
<v-list-item-group class="rounded-xl">
<template v-for="item in associations">
@ -1460,6 +1408,8 @@ import {
PointElement,
LineElement
} from "chart.js"
import NicknameDialog from "@/components/NicknameDialog"
import UserDialog from "@/components/UserDialog"
ChartJS.register(
Title,
@ -1472,7 +1422,7 @@ ChartJS.register(
)
export default {
name: "CommunicationsChat",
components: { CommsInput, Chart },
components: { UserDialog, NicknameDialog, CommsInput, Chart },
props: ["chat", "loading", "items"],
data: () => ({
graphOptions: {
@ -1615,41 +1565,7 @@ export default {
},
openUserPanel(user) {
this.context.userPopout.item = user
this.context.userPopout.item.mutualGroups = []
this.context.userPopout.item.mutualFriends = []
this.context.userPopout.item.loading = {
mutualGroups: true,
mutualFriends: true
}
this.context.userPopout.value = true
this.axios
.get(
process.env.VUE_APP_BASE_URL +
"/api/v1/communications/mutual/" +
user.id +
"/groups"
)
.then((res) => {
this.context.userPopout.item.mutualGroups = res.data
this.context.userPopout.item.loading.mutualGroups = false
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
this.axios
.get(
process.env.VUE_APP_BASE_URL +
"/api/v1/communications/mutual/" +
user.id +
"/friends"
)
.then((res) => {
this.context.userPopout.item.mutualFriends = res.data
this.context.userPopout.item.loading.mutualFriends = false
})
.catch((e) => {
AjaxErrorHandler(this.$store)(e)
})
},
getName(user) {
if (user.nickname?.nickname) {
@ -1757,7 +1673,6 @@ export default {
img.src = link
},
handleDrag(e) {
console.log(1)
if (e.dataTransfer.files.length) {
this.file = e.dataTransfer.files[0]
}
@ -1919,7 +1834,6 @@ export default {
this.message = drafts[this.$route.params.id]
}
this.$socket.on("message", (message) => {
console.log(this.chat)
if (message.chatId === this.chat.chatId) {
this.messages.push(message)
this.autoScroll()

View File

@ -1,6 +1,6 @@
<template>
<div id="communications-friends">
<v-card color="card" class="rounded-xl" :height="viewport()">
<v-card color="card" class="rounded-xl" :height="viewport()" elevation="0">
<v-tabs centered background-color="card" v-model="tab">
<v-tab :key="0">Users</v-tab>
<v-tab :key="1"> Friends </v-tab>
@ -341,6 +341,12 @@ export default {
this.$socket.on("friendRequest", () => {
this.getFriends()
})
this.$socket.on("friendUpdate", () => {
this.getFriends()
})
this.$socket.on("friendAccepted", () => {
this.getFriends()
})
}
}
</script>

View File

@ -30,7 +30,8 @@
<v-form ref="form" class="pa-4 pt-6">
<p class="text-center text-h4">
Login to
<span class="troplo-title">{{ $store.state.site.name }}</span>
<span class="troplo-title">{{ $store.state.site.name }}</span
><small style="font-size: 15px">beta</small>
</p>
<v-text-field
@keyup.enter="doLogin()"
@ -61,6 +62,7 @@
color="primary"
text
@click="$router.push('/register')"
v-if="$store.state.site.allowRegistrations"
>
Register
</v-btn>

View File

@ -8,10 +8,11 @@
<v-form ref="form" class="pa-4 pt-6">
<p class="text-center text-h4">
Login to
<span class="troplo-title">{{ $store.state.site.name }}</span>
<span class="troplo-title">{{ $store.state.site.name }}</span
><small style="font-size: 15px">beta</small>
</p>
<v-text-field
@keyup.enter="doLogin()"
@keyup.enter="doRegister()"
class="rounded-xl"
v-model="username"
label="Username"
@ -19,7 +20,7 @@
type="email"
></v-text-field>
<v-text-field
@keyup.enter="doLogin()"
@keyup.enter="doRegister()"
class="rounded-xl"
v-model="email"
label="Email"
@ -27,7 +28,7 @@
type="email"
></v-text-field>
<v-text-field
@keyup.enter="doLogin()"
@keyup.enter="doRegister()"
class="rounded-xl"
v-model="password"
color="blue accent-7"

View File

@ -150,8 +150,8 @@ export default {
css: "",
autoCSS: false,
fonts: [
"Roboto",
"Inter",
"Roboto",
"JetBrains Mono",
"Montserrat",
"Ubuntu",

View File

@ -1,5 +1,5 @@
<template>
<div id="settings-site">
<div id="settings-site" v-if="$store.state.user?.id">
<v-card-text>
<div class="d-flex">
<v-hover v-slot="{ hover }">
@ -65,6 +65,15 @@
>
Save
</v-btn>
<v-btn
color="red"
text
@click="
$store.dispatch('logout')
$router.push('/login')
"
>Logout</v-btn
>
</v-card-actions>
</div>
</template>

2242
yarn.lock

File diff suppressed because it is too large Load Diff