├── .dockerignore ├── .github └── workflows │ └── build-exe.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json └── packages ├── server ├── app.js ├── config.js ├── package.json ├── routes │ ├── api.js │ └── index.js ├── socket.js └── utils │ ├── index.js │ ├── logger.js │ └── whitelist.js └── ui ├── .env.development ├── .eslintrc.js ├── babel.config.js ├── package.json ├── public ├── img │ └── icons │ │ ├── favicon.png │ │ ├── ts3_manager.png │ │ └── ts3_manager.svg ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.vue ├── api │ └── TeamSpeak.js ├── assets │ ├── css │ │ └── style.css │ └── ts3_manager_logo.svg ├── components │ ├── ApiKeyAdd.vue │ ├── ApiKeys.vue │ ├── AppShell.vue │ ├── AutocompleteSelectChips.vue │ ├── BanAdd.vue │ ├── BanEdit.vue │ ├── BanForm.vue │ ├── Bans.vue │ ├── BellIcon.vue │ ├── ChannelAdd.vue │ ├── ChannelClientPermissions.vue │ ├── ChannelEdit.vue │ ├── ChannelForm.vue │ ├── ChannelGroupEdit.vue │ ├── ChannelGroupPermissions.vue │ ├── ChannelGroups.vue │ ├── ChannelPermissions.vue │ ├── ChannelSpacerAdd.vue │ ├── ClientAvatar.vue │ ├── ClientBan.vue │ ├── ClientEdit.vue │ ├── ClientPermissions.vue │ ├── Clients.vue │ ├── Complaints.vue │ ├── Console.vue │ ├── DarkModeSwitch.vue │ ├── Dashboard.vue │ ├── DashboardClientHistory.vue │ ├── DashboardClientsOnline.vue │ ├── DashboardConnectionTime.vue │ ├── DashboardServerInfo.vue │ ├── FileBrowser.vue │ ├── FileBrowserFile.vue │ ├── FileBrowserFolder.vue │ ├── FileDeleteButton.vue │ ├── FileDeleteDialog.vue │ ├── FileRefreshButton.vue │ ├── FileRenameDialog.vue │ ├── FileUpload.vue │ ├── FileUploadIcon.vue │ ├── FileUploadList.vue │ ├── GroupClientList.vue │ ├── GroupList.vue │ ├── KeyTextField.vue │ ├── Login.vue │ ├── Logo.vue │ ├── Logout.vue │ ├── NotFound.vue │ ├── PermissionTable.vue │ ├── ServerCreate.vue │ ├── ServerEdit.vue │ ├── ServerGroupEdit.vue │ ├── ServerGroupPermissions.vue │ ├── ServerGroups.vue │ ├── ServerLogs.vue │ ├── ServerSelection.vue │ ├── ServerSnapshot.vue │ ├── ServerViewer.vue │ ├── ServerViewerChannel.vue │ ├── ServerViewerClient.vue │ ├── Servers.vue │ ├── Spacer.vue │ ├── SpacerSpecial.vue │ ├── Test copy.vue │ ├── Test.vue │ ├── TextMessageChannel.vue │ ├── TextMessageClient.vue │ ├── TextMessages.vue │ ├── TokenAdd.vue │ └── Tokens.vue ├── main.js ├── mixins │ ├── chart.js │ └── fileTransfer.js ├── plugins │ └── vuetify.js ├── registerServiceWorker.js ├── router │ ├── index.js │ └── routes.js ├── socket.js ├── store │ ├── index.js │ └── modules │ │ ├── avatars.js │ │ ├── chat.js │ │ ├── logs.js │ │ ├── query.js │ │ ├── settings.js │ │ └── uploads.js └── styles │ └── variables.scss └── vue.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Mac os files 2 | .DS_Store 3 | 4 | # Ignore git files 5 | ./git 6 | 7 | # Log files 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | *.log 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw* 21 | 22 | # Excludes node_modules and dist directories 23 | node_modules 24 | dist 25 | 26 | # Ignore files which are used for development 27 | test.js 28 | /frontend/src/components/Test* 29 | README.md 30 | .pw 31 | *.exe 32 | -------------------------------------------------------------------------------- /.github/workflows/build-exe.yml: -------------------------------------------------------------------------------- 1 | name: Build Executables 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish-release: 10 | name: Publish for ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | include: 16 | - os: windows-latest 17 | file: server.exe 18 | file-name: ts3-manager-win-x64-${{ github.event.release.tag_name }}.exe 19 | - os: ubuntu-latest 20 | file: server 21 | file-name: ts3-manager-linux-x64-${{ github.event.release.tag_name }} 22 | - os: macos-latest 23 | file: server 24 | file-name: ts3-manager-macos-x64-${{ github.event.release.tag_name }} 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 16.x 34 | - run: | 35 | npm install 36 | npm run build --if-present 37 | 38 | - name: Upload binaries 39 | uses: svenstaro/upload-release-action@v2 40 | with: 41 | repo_token: ${{ secrets.GITHUB_TOKEN }} 42 | file: packages/server/${{ matrix.file }} 43 | asset_name: ${{ matrix.file-name }} 44 | tag: ${{ github.ref }} 45 | overwrite: true 46 | 47 | publish-docker: 48 | name: Publish Docker images 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v2 54 | 55 | - name: Docker Setup QEMU 56 | uses: docker/setup-qemu-action@v1 57 | with: 58 | platforms: arm64,arm 59 | 60 | - name: Docker Setup Buildx 61 | uses: docker/setup-buildx-action@v1 62 | 63 | - name: Docker Login 64 | uses: docker/login-action@v1 65 | with: 66 | username: ${{ secrets.DOCKER_USER }} 67 | password: ${{ secrets.DOCKER_TOKEN }} 68 | 69 | - name: Prepare 70 | id: prep 71 | run: | 72 | DOCKER_IMAGE=${{ secrets.DOCKER_USER }}/ts3-manager 73 | TAGS="${DOCKER_IMAGE}:latest" 74 | 75 | # If event is release, add release version tag 76 | if [[ $GITHUB_EVENT_NAME == release ]]; then 77 | TAGS="$TAGS,${DOCKER_IMAGE}:${{ github.event.release.tag_name }}" 78 | fi 79 | 80 | # Set output parameters. 81 | echo ::set-output name=tags::${TAGS} 82 | 83 | - name: Build and push 84 | uses: docker/build-push-action@v2 85 | with: 86 | context: . 87 | platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 88 | push: true 89 | tags: ${{ steps.prep.outputs.tags }} 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # System files 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Dependency directories 6 | node_modules/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | .env 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | *.log 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw* 27 | 28 | # Backup files 29 | *.old 30 | *.bak 31 | 32 | # Compiled frontend (Vue.js) 33 | dist 34 | 35 | # Password store for local testing 36 | .pw 37 | 38 | # Test files 39 | test.js 40 | 41 | # Executables created with zeit/pkg 42 | *.exe 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # package local-echo needs git for installation 2 | # alpine image of node does not have git installed 3 | FROM node:16 as build 4 | 5 | # create the directory "app" inside the docker image and set it to the default directory 6 | WORKDIR /app 7 | 8 | # copy the files into the workdir (node_modules are excluded by ignore file) 9 | COPY . . 10 | 11 | # download all the packages for the ui and the server 12 | # build app for production with minification 13 | RUN npm install --prefix ./packages/ui && \ 14 | npm run build --prefix ./packages/ui && \ 15 | npm install --prefix ./packages/server 16 | 17 | FROM node:22-alpine 18 | 19 | WORKDIR /app 20 | 21 | COPY --from=build /app/packages/ui/dist ./packages/ui/dist 22 | COPY --from=build /app/packages/server ./packages/server 23 | 24 | # the webserver will look for the environment variables "PORT" and "NODE_ENV" 25 | ENV PORT 8080 26 | ENV NODE_ENV=production 27 | 28 | # the webserver port 29 | EXPOSE ${PORT} 30 | 31 | # starts the webserver (backend) 32 | # info: in the exec form it is not possible to access environment variables 33 | CMD ["npm", "start", "--prefix", "./packages/server"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jonathan Francke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![TS3 Manager](https://www.ts3.app/ts3_manager_text_new_2.svg) 2 | 3 | ![Docker Pulls](https://img.shields.io/docker/pulls/joni1802/ts3-manager) 4 | ![Github Stars](https://img.shields.io/github/stars/joni1802/ts3-manager) 5 | [![GitHub issues](https://img.shields.io/github/issues/joni1802/ts3-manager)](https://github.com/joni1802/ts3-manager/issues) 6 | [![GitHub license](https://img.shields.io/github/license/joni1802/ts3-manager)](https://github.com/joni1802/ts3-manager/blob/master/LICENSE) 7 | 8 | ## What is TS3 Manager 🤔 9 | The TS3 Manager is a webinterface that allows you to maintain your TeamSpeak server from everywhere over a browser. If you just want to download and install it on your own server or find out more about this project, please go to the [official webpage](https://www.ts3.app). 10 | 11 | ![Screen Recording TS3 Manager Server Viewer](https://i.imgur.com/uP3XgKi.png) 12 | 13 | ## To The Docs 📃 14 | - How to use the Docker image? 🐳 [www.ts3.app/guide/Installation.html#docker](https://www.ts3.app/guide/installation.html#docker) 15 | - How to use the executable? 💾 [www.ts3.app/guide/installation.html](https://www.ts3.app/guide/installation.html) 16 | - Want to compile it yourself or contribute to the project? 🐱‍💻 [www.ts3.app/guide/Developers.html](https://www.ts3.app/guide/Developers.html) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | ts3-manger: 5 | image: "joni1802/ts3-manager" 6 | ports: 7 | - 8080:8080 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts3-manager", 3 | "version": "v2.2.1", 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "dev": "concurrently --kill-others --names \"UI,SERVER\" -c \"bgGreen.black,bgMagenta.black\" \"npm run serve --workspace=@ts3-manager/ui\" \"npm run dev --workspace=@ts3-manager/server\"", 9 | "build": "npm run build --workspace=@ts3-manager/ui --workspace=@ts3-manager/server", 10 | "server:start": "npm run start --workspace=@ts3-manager/server", 11 | "server:dev": "npm run dev --workspace=@ts3-manager/server", 12 | "server:build": "npm run build --workspace=@ts3-manager/server", 13 | "ui:build": "npm run build --workspace=@ts3-manager/ui", 14 | "ui:serve": "npm run serve --workspace=@ts3-manager/ui" 15 | }, 16 | "postcss": { 17 | "plugins": { 18 | "autoprefixer": {} 19 | } 20 | }, 21 | "devDependencies": { 22 | "concurrently": "^6.2.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/server/app.js: -------------------------------------------------------------------------------- 1 | // Read .env file 2 | require("dotenv").config(); 3 | 4 | const config = require("./config"); 5 | const path = require("path"); 6 | const express = require("express"); 7 | const app = express(); 8 | const socket = require("./socket"); 9 | const cookieParser = require("cookie-parser"); 10 | const cors = require("cors"); 11 | const routes = require("./routes"); 12 | 13 | // Enable cross-origin resource sharing for the frontend in development 14 | const corsOptions = { 15 | origin: process.env.NODE_ENV === "development" ? true : false, 16 | credentials: true, 17 | }; 18 | 19 | app.use(cors(corsOptions)); 20 | 21 | app.use(cookieParser()); 22 | 23 | app.use(express.static(path.join(__dirname, "../ui/dist/"))); 24 | 25 | app.use("/api", routes.api); 26 | 27 | app.get("/*", (req, res) => { 28 | // path must be absolute or specify root to res.sendFile 29 | res.sendFile(path.join(__dirname, "../ui/dist/index.html")); 30 | }); 31 | 32 | const server = app.listen(config.port, () => { 33 | console.log(`Server listening on http://127.0.0.1:${config.port}`); 34 | }); 35 | 36 | socket.init(server, corsOptions); 37 | -------------------------------------------------------------------------------- /packages/server/config.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const { program } = require("commander"); 3 | 4 | program.option("-p, --port ", "port the server is listening on"); 5 | program.option( 6 | "-s, --secret ", 7 | "secret for decrypting and encrypting the token" 8 | ); 9 | program.option( 10 | "-w, --whitelist ", 11 | "comma separated list of TeamSpeak servers you can connect to (ip or domain)", 12 | parseWhitelist 13 | ); 14 | 15 | program.parse(process.argv); 16 | 17 | function parseWhitelist(value) { 18 | return value ? value.toLowerCase().split(",") : false; 19 | } 20 | 21 | // process order of the parameters: command line > environment variable > default value 22 | module.exports = { 23 | port: program.port || process.env.PORT || 3000, 24 | secret: 25 | program.secret || 26 | process.env.JWT_SECRET || 27 | crypto.randomBytes(256).toString("base64"), 28 | whitelist: program.whitelist || parseWhitelist(process.env.WHITELIST) || [], 29 | }; 30 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ts3-manager/server", 3 | "private": true, 4 | "description": "Webserver with websockets which handels the connection to the teamspeak serverquery", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "nodemon app.js", 9 | "start": "node app.js", 10 | "build": "pkg . --target node16" 11 | }, 12 | "author": "J.F.", 13 | "license": "ISC", 14 | "dependencies": { 15 | "busboy": "^0.3.1", 16 | "commander": "^6.2.1", 17 | "cookie": "^0.4.1", 18 | "cookie-parser": "^1.4.5", 19 | "cors": "^2.8.5", 20 | "dotenv": "^8.2.0", 21 | "express": "^4.18.2", 22 | "jsonwebtoken": "^8.5.1", 23 | "node-fetch": "^2.7.0", 24 | "socket.io": "^4.5.4", 25 | "ts3-nodejs-library": "^2.4.4", 26 | "winston": "^3.2.1" 27 | }, 28 | "pkg": { 29 | "scripts": "utils/*.js", 30 | "assets": "../ui/dist/**/**/*" 31 | }, 32 | "bin": "app.js", 33 | "devDependencies": { 34 | "nodemon": "^2.0.20", 35 | "pkg": "^5.8.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/routes/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API routes are only used for download and upload streams. 3 | * The main communication between the client (frontend) and the ServerQuery is 4 | * still handled by socket.io. 5 | */ 6 | 7 | const config = require("../config"); 8 | const express = require("express"); 9 | const router = express.Router(); 10 | const jwt = require("jsonwebtoken"); 11 | const { TeamSpeak } = require("ts3-nodejs-library"); 12 | const { logger, whitelist } = require("../utils"); 13 | const { Socket } = require("net"); 14 | const Busboy = require("busboy"); 15 | const Path = require("path"); 16 | const fetch = require("node-fetch"); 17 | 18 | /** 19 | * Get the ip address or hostname of the TeamSpeak server by decoding the cookie 20 | * on every request. 21 | */ 22 | router.use(async (req, res, next) => { 23 | let { token, serverId } = req.cookies; 24 | let ip = req.headers["x-forwarded-for"] || req.connection.remoteAddress; 25 | let log = logger.child({ client: ip }); 26 | 27 | res.locals.log = log; 28 | 29 | try { 30 | let decoded = jwt.verify(token, config.secret); 31 | 32 | whitelist.check(decoded.host); 33 | 34 | res.locals.host = decoded.host; 35 | 36 | next(); 37 | } catch (err) { 38 | next(err); 39 | } 40 | }); 41 | 42 | /** 43 | * Download file from the server. 44 | */ 45 | router.get("/download", async (req, res, next) => { 46 | let { ftkey, port, size, name } = req.query; 47 | let { log, host } = res.locals; 48 | let socket = new Socket(); 49 | 50 | try { 51 | socket.connect(port, host); 52 | 53 | socket.on("connect", () => { 54 | res.setHeader("content-disposition", `attachment; filename=${name}`); 55 | res.setHeader("content-length", size); 56 | 57 | socket.write(ftkey); 58 | 59 | log.info(`Downloading file ${name}`); 60 | 61 | socket.pipe(res); 62 | }); 63 | 64 | socket.on("error", (err) => { 65 | socket.destroy(); 66 | 67 | next(err); 68 | }); 69 | } catch (err) { 70 | next(err); 71 | } 72 | }); 73 | 74 | /** 75 | * Upload file to the server 76 | */ 77 | router.post("/upload", async (req, res, next) => { 78 | let ftkey = req.headers["x-file-transfer-key"]; 79 | let port = req.headers["x-file-transfer-port"]; 80 | let { log, host } = res.locals; 81 | let busboy = new Busboy({ headers: req.headers }); 82 | let socket = new Socket(); 83 | 84 | try { 85 | busboy.on("file", async (fieldname, file, filename, encoding, mimetype) => { 86 | socket.setTimeout(5000); 87 | 88 | socket.connect(port, host); 89 | 90 | socket.on("connect", () => { 91 | socket.write(ftkey); 92 | 93 | log.info(`Start uploading file "${filename}"`); 94 | 95 | file.pipe(socket); 96 | }); 97 | 98 | socket.on("error", async (err) => { 99 | socket.destroy(); 100 | 101 | next(err); 102 | }); 103 | 104 | socket.on("timeout", () => { 105 | log.info(`Stopped uploading file "${filename}"`); 106 | 107 | socket.end(); 108 | }); 109 | 110 | busboy.on("finish", async () => { 111 | log.info(`Finished uploading file "${filename}"`); 112 | 113 | socket.end(); 114 | 115 | res.sendStatus(200); 116 | }); 117 | }); 118 | 119 | req.pipe(busboy); 120 | } catch (err) { 121 | next(err); 122 | } 123 | }); 124 | 125 | /** 126 | * Get newest available TeamSpeak server versions. 127 | * The fetch is run on the server side to bypass CORS restrictions on the client side. 128 | */ 129 | router.get("/teamspeak-versions", async (req, res, next) => { 130 | try { 131 | let data = await fetch( 132 | "https://www.teamspeak.com/versions/server.json" 133 | ).then((data) => data.json()); 134 | 135 | res.json(data); 136 | } catch (err) { 137 | next(err); 138 | } 139 | }); 140 | 141 | /** 142 | * Handle errors. 143 | * This middleware needs to have 4 arguments in the callback function. 144 | * Otherwise express.js will not handle it as an error middleware. 145 | */ 146 | router.use((error, req, res, next) => { 147 | let { log } = res.locals; 148 | 149 | log.error(error.message); 150 | 151 | res.status(400).send(error.message); 152 | }); 153 | 154 | module.exports = router; 155 | -------------------------------------------------------------------------------- /packages/server/routes/index.js: -------------------------------------------------------------------------------- 1 | const api = require("./api"); 2 | 3 | module.exports = { 4 | api, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/server/utils/index.js: -------------------------------------------------------------------------------- 1 | const logger = require("./logger"); 2 | const whitelist = require("./whitelist"); 3 | 4 | module.exports = { 5 | logger, 6 | whitelist, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/utils/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require("winston"); 2 | const path = require("path"); 3 | 4 | // If there is a query parameter which contains a password, it will be not logged in clear text. 5 | // E.g channeledit channel_password=**** cid=5 6 | const hidePasswords = winston.format((info) => { 7 | let password = /password/; 8 | let queryParams = info.message.params; 9 | 10 | for (let prop in queryParams) { 11 | // Replaces password with * 12 | if (password.test(prop)) 13 | queryParams[prop] = "*".repeat(queryParams[prop].length); 14 | } 15 | 16 | return info; 17 | }); 18 | 19 | const myFormat = winston.format.printf( 20 | ({ level, message, label, timestamp, client }) => { 21 | return `${timestamp} | ${client} | ${level.toUpperCase()} | ${message}`; 22 | } 23 | ); 24 | 25 | const logger = winston.createLogger({ 26 | format: winston.format.combine( 27 | winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 28 | hidePasswords(), 29 | myFormat 30 | ), 31 | transports: [ 32 | // Save log files and show them in the console 33 | new winston.transports.File({ 34 | // Using process.cwd instead of __dirname because the executable is using the folder snapshot 35 | // See more under https://github.com/zeit/pkg#snapshot-filesystem 36 | // A relativ path like './combined.log' would also work but is not that clean 37 | filename: path.join(process.cwd(), "combined.log"), 38 | maxsize: 10000000, // 10 MB 39 | maxFiles: 1, 40 | }), 41 | new winston.transports.File({ 42 | filename: path.join(process.cwd(), "error.log"), 43 | level: "error", 44 | maxsize: 10000000, // 10 MB 45 | maxFiles: 1, 46 | }), 47 | new winston.transports.Console(), 48 | ], 49 | }); 50 | 51 | module.exports = logger; 52 | -------------------------------------------------------------------------------- /packages/server/utils/whitelist.js: -------------------------------------------------------------------------------- 1 | const config = require("../config"); 2 | 3 | /** 4 | * Check if the domain or ip of the TeamSpeak server is whitelisted. 5 | * If the whitelist is empty, every connection is accepted. 6 | * @param {String} host IP or domain of the TeamSpeak server 7 | * @return {Boolean} 8 | */ 9 | const check = (host) => { 10 | if ( 11 | !config.whitelist.length || 12 | config.whitelist.includes(host.toLowerCase()) 13 | ) { 14 | return true; 15 | } else { 16 | throw new Error("TeamSpeak server is not whitelisted"); 17 | } 18 | }; 19 | 20 | module.exports = { 21 | check, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/ui/.env.development: -------------------------------------------------------------------------------- 1 | VUE_APP_WEBSOCKET_URI=http://127.0.0.1:3000 2 | -------------------------------------------------------------------------------- /packages/ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:vue/base' 4 | ], 5 | rules: { 6 | 'no-console': 'off', 7 | }, 8 | parser: "vue-eslint-parser", 9 | parserOptions: { 10 | parser: "babel-eslint", 11 | ecmaVersion: 8, 12 | sourceType: "module" 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /packages/ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ts3-manager/ui", 3 | "private": true, 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "build": "vue-cli-service build", 7 | "lint": "vue-cli-service lint" 8 | }, 9 | "dependencies": { 10 | "@ungap/event-target": "^0.2.2", 11 | "axios": "^0.21.1", 12 | "chart.js": "^3.2.1", 13 | "file-saver": "^2.0.2", 14 | "js-cookie": "^2.2.1", 15 | "local-echo": "github:wavesoft/local-echo", 16 | "localforage": "^1.9.0", 17 | "nprogress": "^0.2.0", 18 | "path-browserify": "^1.0.1", 19 | "register-service-worker": "^1.6.2", 20 | "secure-ls": "^1.2.6", 21 | "socket.io-client": "^4.1.1", 22 | "v-clipboard": "^2.2.2", 23 | "vue": "^2.6.12", 24 | "vue-router": "^3.0.2", 25 | "vue-toast-notification": "^0.6.2", 26 | "vuetify": "^2.3.10", 27 | "vuex": "^3.1.1", 28 | "vuex-persistedstate": "^2.7.1", 29 | "xterm": "^4.9.0", 30 | "xterm-addon-fit": "^0.4.0" 31 | }, 32 | "devDependencies": { 33 | "@mdi/font": "^4.5.95", 34 | "@vue/cli-plugin-babel": "^3.12.1", 35 | "@vue/cli-plugin-eslint": "^3.12.1", 36 | "@vue/cli-plugin-pwa": "^3.12.1", 37 | "@vue/cli-service": "^3.12.1", 38 | "babel-eslint": "^10.0.1", 39 | "eslint": "^5.8.0", 40 | "eslint-plugin-vue": "^5.0.0", 41 | "prettier": "2.3.0", 42 | "sass": "^1.26.10", 43 | "sass-loader": "^7.3.1", 44 | "stylus": "^0.54.5", 45 | "stylus-loader": "^3.0.1", 46 | "vue-cli-plugin-vuetify": "^2.0.7", 47 | "vue-template-compiler": "^2.6.12", 48 | "vuetify-loader": "^1.6.0" 49 | }, 50 | "eslintConfig": { 51 | "root": true, 52 | "env": { 53 | "node": true 54 | }, 55 | "extends": [ 56 | "plugin:vue/essential", 57 | "eslint:recommended" 58 | ], 59 | "parserOptions": { 60 | "parser": "babel-eslint" 61 | } 62 | }, 63 | "postcss": { 64 | "plugins": { 65 | "autoprefixer": {} 66 | } 67 | }, 68 | "browserslist": [ 69 | "> 1%", 70 | "last 2 versions", 71 | "not ie <= 8" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /packages/ui/public/img/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joni1802/ts3-manager/abe78b20ce0dc3149c939ccb433afadcde09a242/packages/ui/public/img/icons/favicon.png -------------------------------------------------------------------------------- /packages/ui/public/img/icons/ts3_manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joni1802/ts3-manager/abe78b20ce0dc3149c939ccb433afadcde09a242/packages/ui/public/img/icons/ts3_manager.png -------------------------------------------------------------------------------- /packages/ui/public/img/icons/ts3_manager.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | TS3 Manager 9 | 10 | 11 | 12 | 17 | 18 | 19 | 22 |
...Loading
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TS3 Manager", 3 | "short_name": "TS3 Manager", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/ts3_manager.png", 7 | "sizes": "380x380", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "/", 12 | "display": "standalone", 13 | "background_color": "#ffffff", 14 | "theme_color": "#1c2537" 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /packages/ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 52 | -------------------------------------------------------------------------------- /packages/ui/src/assets/css/style.css: -------------------------------------------------------------------------------- 1 | .card-border { 2 | border: solid 1px #fafafa !important; 3 | } 4 | 5 | .toast { 6 | font-family: 'Roboto', sans-serif; 7 | font-weight: 300; 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/src/assets/ts3_manager_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/src/components/ApiKeyAdd.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 114 | -------------------------------------------------------------------------------- /packages/ui/src/components/ApiKeys.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 149 | -------------------------------------------------------------------------------- /packages/ui/src/components/AutocompleteSelectChips.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 65 | -------------------------------------------------------------------------------- /packages/ui/src/components/BanAdd.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | -------------------------------------------------------------------------------- /packages/ui/src/components/BanEdit.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 66 | -------------------------------------------------------------------------------- /packages/ui/src/components/BanForm.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 220 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelAdd.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelClientPermissions.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 199 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelEdit.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 117 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelGroupPermissions.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 161 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelGroups.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 96 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelPermissions.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 149 | -------------------------------------------------------------------------------- /packages/ui/src/components/ChannelSpacerAdd.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/ui/src/components/ClientAvatar.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /packages/ui/src/components/ClientBan.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 61 | -------------------------------------------------------------------------------- /packages/ui/src/components/ClientPermissions.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 155 | -------------------------------------------------------------------------------- /packages/ui/src/components/Clients.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 181 | -------------------------------------------------------------------------------- /packages/ui/src/components/Complaints.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 148 | -------------------------------------------------------------------------------- /packages/ui/src/components/Console.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 111 | 112 | 115 | -------------------------------------------------------------------------------- /packages/ui/src/components/DarkModeSwitch.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /packages/ui/src/components/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /packages/ui/src/components/DashboardClientHistory.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /packages/ui/src/components/DashboardClientsOnline.vue: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/ui/src/components/DashboardConnectionTime.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /packages/ui/src/components/DashboardServerInfo.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileBrowserFile.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileBrowserFolder.vue: -------------------------------------------------------------------------------- 1 | 87 | 88 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileDeleteButton.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileDeleteDialog.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileRefreshButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileRenameDialog.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileUpload.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 66 | -------------------------------------------------------------------------------- /packages/ui/src/components/FileUploadList.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/ui/src/components/GroupClientList.vue: -------------------------------------------------------------------------------- 1 | 98 | 166 | -------------------------------------------------------------------------------- /packages/ui/src/components/KeyTextField.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 36 | -------------------------------------------------------------------------------- /packages/ui/src/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /packages/ui/src/components/Logout.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | -------------------------------------------------------------------------------- /packages/ui/src/components/NotFound.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerCreate.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 113 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerGroupEdit.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 172 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerGroupPermissions.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 167 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerGroups.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 90 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerSelection.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerSnapshot.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 130 | -------------------------------------------------------------------------------- /packages/ui/src/components/ServerViewerChannel.vue: -------------------------------------------------------------------------------- 1 | 96 | 159 | 160 | 165 | -------------------------------------------------------------------------------- /packages/ui/src/components/Spacer.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /packages/ui/src/components/SpacerSpecial.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /packages/ui/src/components/Test copy.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 95 | -------------------------------------------------------------------------------- /packages/ui/src/components/Test.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 84 | -------------------------------------------------------------------------------- /packages/ui/src/components/TextMessageChannel.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/ui/src/components/TextMessageClient.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /packages/ui/src/components/TokenAdd.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 155 | -------------------------------------------------------------------------------- /packages/ui/src/components/Tokens.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 145 | -------------------------------------------------------------------------------- /packages/ui/src/main.js: -------------------------------------------------------------------------------- 1 | /**************************************************** 2 | !!! THE ORDER OF THE IMPORTED MODULES MATTERS !!! * 3 | The TeamSpeak instance needs to be imported * 4 | before the store, router and socket. * 5 | ****************************************************/ 6 | import "./assets/css/style.css"; 7 | 8 | import Vue from "vue"; 9 | import App from "./App.vue"; 10 | import vuetify from "./plugins/vuetify"; 11 | import VueToast from "vue-toast-notification"; 12 | import "vue-toast-notification/dist/theme-sugar.css"; 13 | import "nprogress/nprogress.css"; 14 | import NProgress from "nprogress"; 15 | import Clipboard from "v-clipboard"; 16 | 17 | import TeamSpeak from "./api/TeamSpeak"; 18 | import "./registerServiceWorker"; 19 | 20 | import store from "./store"; 21 | import router from "./router"; 22 | import socket from "./socket"; 23 | 24 | (async () => { 25 | NProgress.configure({ 26 | showSpinner: false, 27 | }); 28 | 29 | Vue.use(Clipboard); 30 | 31 | Vue.use(VueToast, { 32 | position: "top", 33 | duration: 4000, 34 | }); 35 | 36 | Vue.config.productionTip = false; 37 | 38 | // Connect to websocket server 39 | socket.open(); 40 | 41 | if (!store.state.query.loggedOut) { 42 | try { 43 | await TeamSpeak.reconnect(); 44 | } catch (err) { 45 | console.log(err); 46 | } 47 | } 48 | 49 | // Adding instance properties which are often used in components 50 | Vue.prototype.$socket = socket; 51 | Vue.prototype.$TeamSpeak = TeamSpeak; 52 | 53 | // Render app 54 | new Vue({ 55 | render: (h) => h(App), 56 | router, 57 | store, 58 | vuetify, 59 | }).$mount("#app"); 60 | })(); 61 | -------------------------------------------------------------------------------- /packages/ui/src/mixins/chart.js: -------------------------------------------------------------------------------- 1 | import Chart from "chart.js/auto"; 2 | import store from "@/store"; 3 | 4 | export default { 5 | data() { 6 | return { 7 | chart: null, 8 | }; 9 | }, 10 | computed: { 11 | darkMode() { 12 | return store.state.settings.darkMode; 13 | }, 14 | }, 15 | methods: { 16 | renderChart(ctx, options) { 17 | if (this.darkMode) { 18 | Chart.defaults.scale.grid.color = "#4c5067"; // the grid color 19 | Chart.defaults.scale.grid.borderColor = "#4c5067"; // x and y axis color 20 | Chart.defaults.backgroundColor = "#ff79c6"; // dots color in line chart and label background color 21 | Chart.defaults.borderColor = "#ff79c6"; // line/bar color and label border color 22 | Chart.defaults.color = "#ffffff"; // text color (y and x axis) 23 | } else { 24 | Chart.defaults.scale.grid.color = "rgba(0,0,0,0.1)"; 25 | Chart.defaults.scale.grid.borderColor = "rgba(0,0,0,0.1)"; 26 | Chart.defaults.backgroundColor = "#82B1FF"; 27 | Chart.defaults.borderColor = "#82B1FF"; 28 | Chart.defaults.color = "#000000"; 29 | } 30 | 31 | this.chart = new Chart(ctx, options); 32 | }, 33 | }, 34 | watch: { 35 | darkMode(isEnabled) { 36 | if (isEnabled) { 37 | this.chart.data.datasets[0].backgroundColor = "#ff79c6"; // dots color in line chart and label background color 38 | this.chart.data.datasets[0].borderColor = "#ff79c6"; // line/bar color and label border color 39 | 40 | this.chart.options.scales.y.grid.color = "#4c5067"; // grid color y axis 41 | this.chart.options.scales.x.grid.color = "#4c5067"; // grid color x axis 42 | this.chart.options.scales.y.grid.borderColor = "#4c5067"; // y axis color 43 | this.chart.options.scales.x.grid.borderColor = "#4c5067"; // x axis color 44 | 45 | this.chart.options.scales.y.ticks.color = "#ffffff"; // text color y axis 46 | this.chart.options.scales.x.ticks.color = "#ffffff"; // text color x axis 47 | 48 | this.chart.options.plugins.legend.labels.color = "#ffffff"; // text color label 49 | } else { 50 | this.chart.data.datasets[0].backgroundColor = "#82B1FF"; 51 | this.chart.data.datasets[0].borderColor = "#82B1FF"; 52 | 53 | this.chart.options.scales.y.grid.color = "rgba(0,0,0,0.1)"; 54 | this.chart.options.scales.x.grid.color = "rgba(0,0,0,0.1)"; 55 | this.chart.options.scales.y.grid.borderColor = "rgba(0,0,0,0.1)"; 56 | this.chart.options.scales.x.grid.borderColor = "rgba(0,0,0,0.1)"; 57 | 58 | this.chart.options.scales.y.ticks.color = "#000000"; 59 | this.chart.options.scales.x.ticks.color = "#000000"; 60 | 61 | this.chart.options.plugins.legend.labels.color = "#000000"; 62 | } 63 | 64 | this.chart.update(); 65 | }, 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /packages/ui/src/mixins/fileTransfer.js: -------------------------------------------------------------------------------- 1 | import path from "path-browserify"; 2 | 3 | export default { 4 | methods: { 5 | getFilePath(filePath, filename) { 6 | return path.join(filePath, filename); 7 | }, 8 | getClientFileTransferId() { 9 | return Math.floor(Math.random() * 10000); 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/ui/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuetify from "vuetify/lib"; 3 | import "@mdi/font/css/materialdesignicons.css"; 4 | import store from "@/store"; 5 | 6 | Vue.use(Vuetify); 7 | 8 | export default new Vuetify({ 9 | icons: { 10 | iconfont: "mdi", 11 | }, 12 | theme: { 13 | dark: store.state.settings.darkMode, 14 | themes: { 15 | light: { 16 | primary: "#1976D2", 17 | secondary: "#424242", 18 | accent: "#82B1FF", 19 | error: "#FF5252", 20 | info: "#2196F3", 21 | success: "#4CAF50", 22 | warning: "#FFC107", 23 | }, 24 | dark: { 25 | primary: "#bd93f9", 26 | secondary: "#44475a", 27 | accent: "#6272a4", 28 | error: "#ff5555", 29 | info: "#8be9fd", 30 | success: "#50fa7b", 31 | warning: "#ffb86c", 32 | }, 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /packages/ui/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from "register-service-worker"; 4 | 5 | if (process.env.NODE_ENV === "production") { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | "App is being served from cache by a service worker.\n" + 10 | "For more details, visit https://goo.gl/AFskqB" 11 | ); 12 | }, 13 | registered() { 14 | console.log("Service worker has been registered."); 15 | }, 16 | cached() { 17 | console.log("Content has been cached for offline use."); 18 | }, 19 | updatefound() { 20 | console.log("New content is downloading."); 21 | }, 22 | updated() { 23 | console.log("New content is available; please refresh.🙌"); 24 | }, 25 | offline() { 26 | console.log( 27 | "No internet connection found. App is running in offline mode." 28 | ); 29 | }, 30 | error(error) { 31 | console.error("Error during service worker registration:", error); 32 | }, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /packages/ui/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter from "vue-router"; 3 | import routes from "./routes"; 4 | import store from "../store"; 5 | import NProgress from "nprogress"; 6 | 7 | const router = new VueRouter({ 8 | mode: "history", 9 | routes: routes, 10 | }); 11 | 12 | router.beforeEach((to, from, next) => { 13 | store.commit("isLoading", true); 14 | 15 | NProgress.start(); 16 | 17 | if (to.meta.requiresAuth) { 18 | if (store.state.query.connected) { 19 | next(); 20 | } else { 21 | next({ name: "login" }); 22 | } 23 | } else { 24 | if (to.name === "login" && store.state.query.connected) { 25 | next({ name: "servers" }); 26 | } else { 27 | next(); 28 | } 29 | } 30 | }); 31 | 32 | router.afterEach((to, from) => { 33 | store.commit("isLoading", false); 34 | 35 | setTimeout(() => { 36 | if (store.state.query.loading) { 37 | NProgress.inc(); 38 | } else { 39 | NProgress.done(); 40 | } 41 | }, 0); 42 | }); 43 | 44 | Vue.use(VueRouter); 45 | 46 | export default router; 47 | -------------------------------------------------------------------------------- /packages/ui/src/socket.js: -------------------------------------------------------------------------------- 1 | import io from "socket.io-client"; 2 | import Vue from "vue"; 3 | import store from "./store"; 4 | import router from "./router"; 5 | 6 | let connectErrorToast = {}; 7 | let connectErrorShown = false; 8 | 9 | // Socket connection to the backend 10 | const socket = io(process.env.VUE_APP_WEBSOCKET_URI, { 11 | withCredentials: true, 12 | autoConnect: false, 13 | }); 14 | 15 | // Go to login screen and set connection state to false 16 | const handleLogout = () => { 17 | store.commit("isConnected", false); 18 | 19 | if (router.currentRoute.name !== "login") { 20 | router.push({ name: "login" }); 21 | } 22 | }; 23 | 24 | socket.on("connect_error", (err) => { 25 | if (!connectErrorShown) { 26 | connectErrorToast = Vue.prototype.$toast.error(err.message, { 27 | duration: 0, 28 | }); 29 | 30 | connectErrorShown = true; 31 | 32 | handleLogout(); 33 | } 34 | }); 35 | 36 | socket.on("connect", () => { 37 | if (connectErrorShown) { 38 | connectErrorToast.dismiss(); 39 | 40 | Vue.prototype.$toast.success("Reconnected"); 41 | 42 | connectErrorShown = false; 43 | } 44 | }); 45 | 46 | socket.on("disconnect", handleLogout); 47 | 48 | export default socket; 49 | -------------------------------------------------------------------------------- /packages/ui/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vuex from "vuex"; 2 | import Vue from "vue"; 3 | 4 | import settings from "./modules/settings"; 5 | import query from "./modules/query"; 6 | import chat from "./modules/chat"; 7 | import avatars from "./modules/avatars"; 8 | import uploads from "./modules/uploads"; 9 | 10 | import createPersistedState from "vuex-persistedstate"; 11 | import SecureLS from "secure-ls"; 12 | 13 | const ls = new SecureLS({ isCompression: false }); 14 | 15 | // Vuex with VuexPersistence. The state gets saved as an json object inside localStorage. 16 | // See "https://www.npmjs.com/package/vuex-persistedstate" 17 | Vue.use(Vuex); 18 | 19 | const store = new Vuex.Store({ 20 | actions: { 21 | clearStorage({ dispatch, commit }) { 22 | dispatch("clearConnection"); 23 | commit("removeAllMessages"); 24 | }, 25 | }, 26 | modules: { 27 | settings, 28 | query, 29 | chat, 30 | avatars, 31 | uploads, 32 | }, 33 | plugins: [ 34 | createPersistedState({ 35 | paths: [ 36 | "chat", 37 | "settings", 38 | "query.connected", 39 | "query.queryUser", 40 | "query.loggedOut", 41 | ], 42 | // Encrypt local storage 43 | storage: process.env.NODE_ENV !== "development" && { 44 | getItem: (key) => ls.get(key), 45 | setItem: (key, value) => ls.set(key, value), 46 | removeItem: (key) => ls.remove(key), 47 | }, 48 | }), 49 | ], 50 | }); 51 | 52 | export default store; 53 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/avatars.js: -------------------------------------------------------------------------------- 1 | import TeamSpeak from "@/api/TeamSpeak"; 2 | import Vue from "vue"; 3 | import localForage from "localforage"; 4 | 5 | /** 6 | * The avatar images are stored in IndexedDb because the local storage has a size limit of 5MB. 7 | * VuexPersistence does not support IndexedDb because of its asynchrony. 8 | * So the data gets synchronised manually between the Vuex state and the IndexedDb database. 9 | * @type {Object} 10 | */ 11 | const db = localForage.createInstance({ 12 | driver: localForage.INDEXEDDB, 13 | name: "files", 14 | storeName: "avatars", 15 | }); 16 | 17 | const state = { 18 | // Contains the client database id, information of the avatar file and the file itself as a base64 19 | files: [], 20 | }; 21 | 22 | const mutations = { 23 | saveAvatar(state, avatar) { 24 | state.files.push(avatar); 25 | }, 26 | removeAvatar(state, clientDbId) { 27 | state.files = state.files.filter( 28 | (avatar) => avatar.clientDbId !== clientDbId 29 | ); 30 | }, 31 | }; 32 | 33 | const actions = { 34 | // synchronise IndexedDb database with the Vuex state 35 | async initState({ state, dispatch }) { 36 | try { 37 | await db.iterate((value, key) => { 38 | if (!state.files.find((avatar) => avatar.clientDbId == key)) { 39 | dispatch("saveAvatar", value); 40 | } 41 | }); 42 | } catch (err) { 43 | Vue.prototype.$toast.error(err.message); 44 | } 45 | }, 46 | getAvatarFileInfo(_context, name) { 47 | return TeamSpeak.execute("ftgetfileinfo", { 48 | cid: 0, 49 | cpw: "", // maybe check if the server has a password is needed in this case 50 | name, 51 | }).then((info) => info[0]); 52 | }, 53 | getClientDbInfo(_context, clientDbId) { 54 | return TeamSpeak.execute("clientdbinfo", { 55 | cldbid: clientDbId, 56 | }).then((info) => info[0]); 57 | }, 58 | async saveAvatar({ commit }, avatar) { 59 | try { 60 | commit("saveAvatar", avatar); 61 | 62 | // IndexedDb key does not support numbers 63 | await db.setItem(avatar.clientDbId.toString(), avatar); 64 | } catch (err) { 65 | Vue.prototype.$toast.error(err.message); 66 | } 67 | }, 68 | async removeAvatar({ commit }, clientDbId) { 69 | try { 70 | commit("removeAvatar", clientDbId); 71 | 72 | await db.removeItem(clientDbId.toString()); 73 | } catch (err) { 74 | Vue.prototype.$toast.error(err.message); 75 | } 76 | }, 77 | async getClientAvatars({ dispatch, commit, state }, clientDbIdList) { 78 | await dispatch("initState"); 79 | 80 | for (let clientDbId of clientDbIdList) { 81 | try { 82 | // The serveradmin has no database data 83 | if (clientDbId !== 1) { 84 | let clientDbInfo = await dispatch("getClientDbInfo", clientDbId); 85 | 86 | // If client has an avatar 87 | if (clientDbInfo.client_flag_avatar) { 88 | let fileName = `/avatar_${clientDbInfo.client_base64HashClientUID}`; 89 | let avatarFileInfo = await dispatch("getAvatarFileInfo", fileName); 90 | let currentAvatar = state.files.find( 91 | (avatar) => avatar.name === avatarFileInfo.name 92 | ); 93 | 94 | // Download new avatar file if the datetime has changed or it is not in the list 95 | if ( 96 | !currentAvatar || 97 | currentAvatar.datetime !== avatarFileInfo.datetime 98 | ) { 99 | let base64 = await TeamSpeak.downloadFile(fileName, 0, ""); 100 | 101 | dispatch("removeAvatar", clientDbId); 102 | dispatch("saveAvatar", { 103 | ...avatarFileInfo, 104 | base64, 105 | clientDbId, 106 | }); 107 | } 108 | } else { 109 | dispatch("removeAvatar", clientDbId); 110 | } 111 | } 112 | } catch (err) { 113 | Vue.prototype.$toast.error(err.message); 114 | } 115 | } 116 | }, 117 | }; 118 | 119 | export default { 120 | state, 121 | mutations, 122 | actions, 123 | }; 124 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/chat.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | 3 | const state = { 4 | messages: [], 5 | }; 6 | 7 | const getters = { 8 | unreadMessages: (state, _getters, rootState) => { 9 | return state.messages.filter((message) => { 10 | return ( 11 | message.meta.unread && message.serverId === rootState.query.serverId 12 | ); 13 | }).length; 14 | }, 15 | }; 16 | 17 | const mutations = { 18 | saveMessage(state, message) { 19 | // Only the last 50 messages are stored 20 | if (state.messages.length > 50) state.messages.shift(); 21 | 22 | state.messages.push(message); 23 | }, 24 | markMessageAsRead(state, { target, targetmode }) { 25 | for (let i = 0; i < state.messages.length; i++) { 26 | if ( 27 | state.messages[i].target === target && 28 | state.messages[i].targetmode === targetmode 29 | ) { 30 | state.messages[i].meta.unread = false; 31 | } 32 | } 33 | }, 34 | removeAllMessages(state) { 35 | state.messages = []; 36 | }, 37 | }; 38 | 39 | const actions = { 40 | async handleReceivedMessages({ dispatch, rootState }, notification) { 41 | try { 42 | if (notification.invoker.clid !== rootState.query.queryUser.client_id) { 43 | dispatch("saveTextMessage", { 44 | targetmode: notification.targetmode, 45 | sender: { 46 | clid: notification.invoker.clid, 47 | client_nickname: notification.invoker.client_nickname, 48 | }, 49 | text: notification.msg, 50 | meta: { 51 | unread: true, 52 | }, 53 | }); 54 | } 55 | } catch (err) { 56 | Vue.prototype.$toast.error(err.message); 57 | } 58 | }, 59 | async saveTextMessage( 60 | { commit, rootState }, 61 | { targetmode, sender, text, target, meta } 62 | ) { 63 | try { 64 | if (!target) { 65 | switch (targetmode) { 66 | case 1: 67 | target = sender.clid; 68 | 69 | break; 70 | case 2: 71 | target = rootState.query.queryUser.client_channel_id; 72 | 73 | break; 74 | case 3: 75 | target = rootState.query.serverId; 76 | } 77 | } 78 | 79 | meta.timestamp = new Date(); 80 | 81 | commit("saveMessage", { 82 | sender, 83 | target, 84 | targetmode, 85 | text, 86 | meta, 87 | serverId: rootState.query.serverId, 88 | }); 89 | } catch (err) { 90 | Vue.prototype.$toast.error(err.message); 91 | } 92 | }, 93 | }; 94 | 95 | export default { 96 | state, 97 | getters, 98 | mutations, 99 | actions, 100 | }; 101 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/logs.js: -------------------------------------------------------------------------------- 1 | import TeamSpeak from "@/api/TeamSpeak"; 2 | import Vue from "vue"; 3 | import localForage, { setItem } from "localforage"; 4 | 5 | const db = localForage.createInstance({ 6 | driver: localForage.INDEXEDDB, 7 | name: "cache", 8 | storeName: "logs", 9 | }); 10 | 11 | const state = { 12 | logView: [], 13 | fileSize: 0, 14 | lastPosition: undefined, 15 | }; 16 | 17 | const mutations = { 18 | addLogView(state, logView) { 19 | state.logView.push(...logView); 20 | }, 21 | setLogView(state, data) { 22 | state.logView = data; 23 | }, 24 | setLastPosition(state, position) { 25 | state.lastPosition = position; 26 | }, 27 | }; 28 | 29 | const getLocaleDate = (timestamp) => { 30 | let localeDate = new Date(timestamp); 31 | let milliseconds = 32 | localeDate.getTime() + -localeDate.getTimezoneOffset() * 60 * 1000; 33 | 34 | localeDate.setTime(milliseconds); 35 | 36 | return localeDate; 37 | }; 38 | 39 | const getParsedLogs = (logs) => { 40 | return logs.map(({ l }) => { 41 | let [timestamp, level, channel, sid, ...msg] = l.split("|"); 42 | 43 | return { 44 | timestamp: getLocaleDate(timestamp), 45 | level: level.trim(), 46 | channel: channel.trim(), 47 | sid: parseInt(sid), 48 | msg: msg.join("|"), 49 | }; 50 | }); 51 | }; 52 | 53 | const saveLogView = async (logView) => { 54 | for (let line of logView) { 55 | await db.setItem(line.timestamp.getTime().toString(), line); 56 | } 57 | }; 58 | 59 | const actions = { 60 | // async syncLogViewState({ commit }) { 61 | // console.log("start"); 62 | 63 | // let keys = await db.keys(); 64 | // let arr = []; 65 | 66 | // // IndexedDB data is saved from oldest to newest 67 | // for (let key of keys.reverse()) { 68 | // // Local Forage getItem is to slow 69 | // arr.push(db.getItem(key)); 70 | // } 71 | 72 | // // This is faster 73 | // let res = await Promise.all(arr); 74 | 75 | // commit("setLogView", res); 76 | 77 | // console.log(state.logView); 78 | // console.log("fin"); 79 | // }, 80 | 81 | // // Die Position der bereits geladenen logs mit den aktuellen logs vergleichen und nachträglich laden 82 | // async initLogView({ state, dispatch, commit }) { 83 | // try { 84 | // if (state.logView.length) { 85 | // } 86 | 87 | // let fileSize = state.logView.length ? state.logView[0].file_size : 0; 88 | 89 | // await dispatch("syncLogViewState"); 90 | 91 | // await dispatch("getLogView"); 92 | // } catch (err) { 93 | // Vue.prototype.$toast.error(err.message); 94 | // } 95 | // }, 96 | 97 | // async saveLogView({ commit }, logView) { 98 | // // commit("addLogView", logView); 99 | 100 | // for (let line of logView) { 101 | // await db.setItem(line.timestamp.getTime().toString(), line); 102 | // } 103 | // }, 104 | 105 | // async clearLogView({ commit }) { 106 | // commit("setLogView", []); 107 | // commit("setLastPosition", undefined); 108 | 109 | // try { 110 | // await db.clear(); 111 | // } catch (err) { 112 | // Vue.prototype.$toast.error(err.message); 113 | // } 114 | // }, 115 | 116 | // hier noch als argument position übergeben, der den Wert 0 ersetzt 117 | async getLogView({ dispatch, commit, state }) { 118 | let stop = false; 119 | let lastPosition = 0; 120 | 121 | while (!stop) { 122 | let logs = await TeamSpeak.execute("logview", { 123 | instance: 0, 124 | reverse: 1, 125 | lines: 100, 126 | begin_pos: lastPosition, 127 | }); 128 | 129 | lastPosition = logs[0].last_pos; 130 | 131 | let parsedLogs = getParsedLogs(logs); 132 | 133 | // let index; 134 | 135 | // if (state.logView.length) { 136 | // index = parsedLogs.findIndex( 137 | // (line) => 138 | // state.logView[0].timestamp.getTime() > line.timestamp.getTime() 139 | // ); 140 | // } 141 | 142 | // if (index !== -1) { 143 | // parsedLogs.splice(index); 144 | 145 | // stop = true; 146 | // } 147 | 148 | // await dispatch("saveLogView", parsedLogs); 149 | 150 | await saveLogView(parsedLogs); 151 | 152 | if (lastPosition === 0) stop = true; 153 | } 154 | }, 155 | }; 156 | 157 | export default { 158 | state, 159 | mutations, 160 | actions, 161 | }; 162 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/query.js: -------------------------------------------------------------------------------- 1 | import Cookies from "js-cookie"; 2 | 3 | const state = { 4 | serverId: Cookies.get("serverId"), 5 | token: Cookies.get("token"), 6 | loading: false, 7 | connected: false, 8 | loggedOut: true, 9 | queryUser: {}, 10 | }; 11 | 12 | const mutations = { 13 | isLoading(state, status) { 14 | state.loading = status; 15 | }, 16 | saveUserInfo(state, userData) { 17 | state.queryUser = userData; 18 | }, 19 | setToken(state, token) { 20 | state.token = token; 21 | }, 22 | isConnected(state, status) { 23 | state.connected = status; 24 | }, 25 | setServerId(state, id) { 26 | state.serverId = id; 27 | }, 28 | isLoggedOut(state, status) { 29 | state.loggedOut = status; 30 | }, 31 | }; 32 | 33 | const actions = { 34 | saveToken({ commit, rootState }, token) { 35 | Cookies.set("token", token, { 36 | expires: rootState.settings.rememberLogin 37 | ? new Date(2147483647 * 1000) 38 | : "", 39 | }); 40 | 41 | commit("setToken", token); 42 | }, 43 | removeToken({ commit }) { 44 | Cookies.remove("token"); 45 | 46 | commit("setToken", null); 47 | }, 48 | saveServerId({ commit, rootState }, sid) { 49 | Cookies.set("serverId", sid, { 50 | expires: rootState.settings.rememberLogin 51 | ? new Date(2147483647 * 1000) 52 | : "", 53 | }); 54 | 55 | commit("setServerId", sid); 56 | }, 57 | removeServerId({ commit }) { 58 | Cookies.remove("serverId"); 59 | 60 | commit("setServerId", null); 61 | }, 62 | clearConnection({ commit, rootState, dispatch }) { 63 | commit("isConnected", false); 64 | dispatch("removeServerId"); 65 | commit("saveUserInfo", {}); 66 | 67 | if (!rootState.settings.rememberLogin) dispatch("removeToken"); 68 | }, 69 | saveConnection({ commit, dispatch }, { serverId, queryUser, token }) { 70 | commit("isConnected", true); 71 | 72 | if (serverId) dispatch("saveServerId", serverId); 73 | if (queryUser) commit("saveUserInfo", queryUser); 74 | if (token) dispatch("saveToken", token); 75 | }, 76 | }; 77 | 78 | export default { 79 | state, 80 | mutations, 81 | actions, 82 | }; 83 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/settings.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | rememberLogin: true, 3 | notifications: true, 4 | darkMode: true, 5 | }; 6 | 7 | const mutations = { 8 | setRememberLogin(state, status) { 9 | state.rememberLogin = status; 10 | }, 11 | setNotifications(state, status) { 12 | state.notifications = status; 13 | }, 14 | setDarkMode(state, status) { 15 | state.darkMode = status; 16 | }, 17 | }; 18 | 19 | export default { 20 | state, 21 | mutations, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/ui/src/store/modules/uploads.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | queue: [], 3 | }; 4 | 5 | const getters = { 6 | uploading: (state) => { 7 | return !!state.queue.find((file) => file.uploading); 8 | }, 9 | }; 10 | 11 | const mutations = { 12 | addFileToQueue(state, file) { 13 | state.queue.push(file); 14 | }, 15 | removeFileFromQueue(state, clientftfid) { 16 | let index = state.queue.findIndex( 17 | (file) => file.clientftfid === clientftfid 18 | ); 19 | 20 | state.queue.splice(index, 1); 21 | }, 22 | setFileUploadProgress(state, { clientftfid, percentage }) { 23 | let index = state.queue.findIndex( 24 | (file) => file.clientftfid === clientftfid 25 | ); 26 | 27 | state.queue[index].progress = percentage; 28 | }, 29 | resetUploadState(state) { 30 | for (let i = 0; i < state.queue.length; i++) { 31 | state.queue[i].uploading = false; 32 | } 33 | }, 34 | }; 35 | 36 | export default { 37 | state, 38 | getters, 39 | mutations, 40 | }; 41 | -------------------------------------------------------------------------------- /packages/ui/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // https://vuetifyjs.com/en/customization/sass-variables 2 | 3 | $material-light: ( 4 | "background": #fafafa, 5 | "app-bar": #fff, 6 | ); 7 | 8 | $material-dark: ( 9 | "background": #282a36, 10 | "app-bar": #44475a, 11 | "cards": #44475a, 12 | "chips": #282a36, 13 | "navigation-drawer": #44475a, 14 | "table": ( 15 | "hover": #6272a4, 16 | "group": #6272a4, 17 | "active": #6272a4, 18 | ), 19 | ); 20 | -------------------------------------------------------------------------------- /packages/ui/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintOnSave: undefined, 3 | runtimeCompiler: true, 4 | 5 | pwa: { 6 | name: 'TS3 Manager', 7 | themeColor: '#FAFAFA', 8 | msTileColor: '#FFFFFF', 9 | appleMobileWebAppCapable: 'yes', 10 | appleMobileWebAppStatusBarStyle: 'black', 11 | manifestPath: 'manifest.json', 12 | workboxOptions: { 13 | navigateFallback: 'index.html', 14 | // Ignore api calls because this routes are handled on the server side (backend). 15 | navigateFallbackBlacklist: [/\/api\/.*/], 16 | // To make update on refresh available. 17 | // See https://stackoverflow.com/questions/54145735/vue-pwa-not-getting-new-content-after-refresh 18 | skipWaiting: true 19 | }, 20 | iconPaths: { 21 | favicon32: 'img/icons/favicon.png', 22 | appleTouchIcon: 'img/icons/ts3_manager.png', 23 | maskIcon: 'img/icons/ts3_manager.svg', 24 | msTileImage: 'img/icons/ts3_manager.png' 25 | } 26 | } 27 | } 28 | --------------------------------------------------------------------------------