├── .dockerignore
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ ├── .env.github
│ ├── build-docker.yml
│ ├── build-node.yml
│ └── lint.yml
├── .gitignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── app
├── api
│ ├── BinaryMessageTypes.ts
│ ├── SkyChatClient.ts
│ ├── index.ts
│ └── plugins
│ │ └── MutePluginHelper.ts
├── client
│ ├── .gitignore
│ ├── Dockerfile
│ ├── Dockerfile-dev
│ ├── index.html
│ ├── nginx.default.conf
│ ├── public
│ │ ├── assets
│ │ │ ├── background-light.jpg
│ │ │ ├── background.png
│ │ │ ├── background.svg
│ │ │ ├── favicon.png
│ │ │ ├── images
│ │ │ │ ├── avatars
│ │ │ │ │ ├── default.png
│ │ │ │ │ └── server.png
│ │ │ │ ├── cursors
│ │ │ │ │ ├── animal.gif
│ │ │ │ │ ├── default.png
│ │ │ │ │ └── default.svg
│ │ │ │ ├── icons
│ │ │ │ │ ├── loading.gif
│ │ │ │ │ ├── risibank.png
│ │ │ │ │ ├── twitch.png
│ │ │ │ │ └── video.png
│ │ │ │ └── ranks
│ │ │ │ │ ├── materials
│ │ │ │ │ ├── 0_paper_18.png
│ │ │ │ │ ├── 0_paper_26.png
│ │ │ │ │ ├── 10_topaz_18.png
│ │ │ │ │ ├── 10_topaz_26.png
│ │ │ │ │ ├── 11_ruby_18.png
│ │ │ │ │ ├── 11_ruby_26.png
│ │ │ │ │ ├── 12_saphir_18.png
│ │ │ │ │ ├── 12_saphir_26.png
│ │ │ │ │ ├── 13_emerald_18.png
│ │ │ │ │ ├── 13_emerald_26.png
│ │ │ │ │ ├── 14_diamond_18.png
│ │ │ │ │ ├── 14_diamond_26.png
│ │ │ │ │ ├── 15_diamond_shiny_18.gif
│ │ │ │ │ ├── 15_diamond_shiny_26.gif
│ │ │ │ │ ├── 1_cardboard_18.png
│ │ │ │ │ ├── 1_cardboard_26.png
│ │ │ │ │ ├── 2_plastic_18.png
│ │ │ │ │ ├── 2_plastic_26.png
│ │ │ │ │ ├── 3_stone_18.png
│ │ │ │ │ ├── 3_stone_26.png
│ │ │ │ │ ├── 4_bronze_18.png
│ │ │ │ │ ├── 4_bronze_26.png
│ │ │ │ │ ├── 5_silver_18.png
│ │ │ │ │ ├── 5_silver_26.png
│ │ │ │ │ ├── 6_gold_18.png
│ │ │ │ │ ├── 6_gold_26.png
│ │ │ │ │ ├── 7_platinum_18.png
│ │ │ │ │ ├── 7_platinum_26.png
│ │ │ │ │ ├── 8_onyx_18.png
│ │ │ │ │ ├── 8_onyx_26.png
│ │ │ │ │ ├── 9_amethyst_18.png
│ │ │ │ │ └── 9_amethyst_26.png
│ │ │ │ │ └── tongs
│ │ │ │ │ ├── 0_paper.png
│ │ │ │ │ ├── 1_wood.png
│ │ │ │ │ ├── 2_plastic.png
│ │ │ │ │ ├── 3_bronze.png
│ │ │ │ │ ├── 4_silver.png
│ │ │ │ │ ├── 5_gold.png
│ │ │ │ │ ├── 6_saphir.png
│ │ │ │ │ ├── 7_ruby.png
│ │ │ │ │ ├── 8_emerald.png
│ │ │ │ │ └── 9_diamond.gif
│ │ │ ├── logo.png
│ │ │ └── sound
│ │ │ │ ├── new-poll.ogg
│ │ │ │ ├── notification.mp3
│ │ │ │ ├── roll-end.ogg
│ │ │ │ └── roll.ogg
│ │ ├── favicon.png
│ │ ├── manifest.json
│ │ └── service-worker.js
│ └── src
│ │ ├── App.vue
│ │ ├── assets
│ │ └── logo.png
│ │ ├── components
│ │ ├── common
│ │ │ ├── SkyDropdown.vue
│ │ │ ├── SkyDropdownItem.vue
│ │ │ └── SkyTooltip.vue
│ │ ├── cursor
│ │ │ └── CursorOverlay.vue
│ │ ├── gallery
│ │ │ ├── GalleryFileDotMenu.vue
│ │ │ └── GalleryPannel.vue
│ │ ├── layout
│ │ │ └── AppHeader.vue
│ │ ├── message
│ │ │ ├── MessagePannel.vue
│ │ │ ├── MessageReaction.vue
│ │ │ ├── MessageReactionAdd.vue
│ │ │ ├── MessageReactions.vue
│ │ │ ├── NewMessageForm.vue
│ │ │ └── SingleMessage.vue
│ │ ├── modal
│ │ │ ├── GalleryModal.vue
│ │ │ ├── ManageRoomsModal.vue
│ │ │ ├── ModalTemplate.vue
│ │ │ ├── OngoingConvertsModal.vue
│ │ │ ├── PlayerQueueModal.vue
│ │ │ ├── ProfileModal.vue
│ │ │ ├── VideoConverterModal.vue
│ │ │ └── YoutubeVideoSearcherModal.vue
│ │ ├── player
│ │ │ ├── MediaPlayer.vue
│ │ │ ├── PlayerPannel.vue
│ │ │ └── impl
│ │ │ │ ├── GalleryPlayer.vue
│ │ │ │ ├── IFramePlayer.vue
│ │ │ │ ├── TwitchPlayer.vue
│ │ │ │ └── YoutubePlayer.vue
│ │ ├── playerchannel
│ │ │ ├── PlayerChannelList.vue
│ │ │ └── SinglePlayerChannel.vue
│ │ ├── poll
│ │ │ └── PollList.vue
│ │ ├── room
│ │ │ ├── RoomList.vue
│ │ │ └── SingleRoom.vue
│ │ ├── user
│ │ │ ├── ConnectedList.vue
│ │ │ ├── SingleConnectedUser.vue
│ │ │ ├── UserBigAvatar.vue
│ │ │ ├── UserMiniAvatar.vue
│ │ │ └── UserMiniAvatarCollection.vue
│ │ └── util
│ │ │ ├── ExpandableBlock.vue
│ │ │ ├── HoverCard.vue
│ │ │ ├── SectionSubTitle.vue
│ │ │ └── SectionTitle.vue
│ │ ├── composables
│ │ └── useClientState.js
│ │ ├── css
│ │ ├── index.css
│ │ ├── layout.css
│ │ ├── lib.css
│ │ ├── modal.css
│ │ ├── scrollbar.css
│ │ ├── skychat.css
│ │ └── util.css
│ │ ├── icons.js
│ │ ├── lib
│ │ ├── AudioRecorder.js
│ │ ├── Gallery.js
│ │ └── WebPush.js
│ │ ├── main.js
│ │ ├── stores
│ │ ├── app.js
│ │ └── client.js
│ │ └── views
│ │ ├── Chat.vue
│ │ └── Home.vue
├── database
│ ├── .gitignore
│ └── Dockerfile
├── doc
│ ├── github-banner.svg
│ ├── screenshot-desktop.png
│ ├── screenshot-mobile.png
│ ├── screenshot-ui.png
│ └── setup-youtube.md
├── filebrowser
│ ├── .gitignore
│ ├── Dockerfile
│ ├── settings.json
│ └── start.sh
├── script
│ ├── autoinstall.sh
│ ├── backup.sh
│ ├── debug-db.sh
│ ├── debug-log.sh
│ ├── dev.sh
│ ├── reset.sh
│ ├── setup.sh
│ └── start-server.sh
├── server
│ ├── Dockerfile
│ ├── Dockerfile-dev
│ ├── cli.ts
│ ├── constants.ts
│ ├── index.ts
│ ├── plugins
│ │ ├── GlobalPlugin.ts
│ │ ├── GlobalPluginGroup.ts
│ │ ├── Plugin.ts
│ │ ├── PluginGroup.ts
│ │ ├── RoomPlugin.ts
│ │ ├── core
│ │ │ ├── CorePluginGroup.ts
│ │ │ ├── global
│ │ │ │ ├── AccountPlugin.ts
│ │ │ │ ├── AdminConfigPlugin.ts
│ │ │ │ ├── AudioRecorderPlugin.ts
│ │ │ │ ├── AvatarPlugin.ts
│ │ │ │ ├── BackupPlugin.ts
│ │ │ │ ├── BanPlugin.ts
│ │ │ │ ├── BlacklistPlugin.ts
│ │ │ │ ├── ConnectedListPlugin.ts
│ │ │ │ ├── CustomizationPlugin.ts
│ │ │ │ ├── IpPlugin.ts
│ │ │ │ ├── JoinRoomPlugin.ts
│ │ │ │ ├── KickPlugin.ts
│ │ │ │ ├── MailPlugin.ts
│ │ │ │ ├── MottoPlugin.ts
│ │ │ │ ├── MutePlugin.ts
│ │ │ │ ├── OPPlugin.ts
│ │ │ │ ├── Poll.ts
│ │ │ │ ├── PollPlugin.ts
│ │ │ │ ├── PrivateMessagePlugin.ts
│ │ │ │ ├── ReactionPlugin.ts
│ │ │ │ ├── SetRightPlugin.ts
│ │ │ │ ├── StickerPlugin.ts
│ │ │ │ ├── VoidPlugin.ts
│ │ │ │ ├── WebPushPlugin.ts
│ │ │ │ ├── WelcomePlugin.ts
│ │ │ │ └── XpTickerPlugin.ts
│ │ │ ├── index.ts
│ │ │ └── room
│ │ │ │ ├── HelpPlugin.ts
│ │ │ │ ├── MentionPlugin.ts
│ │ │ │ ├── MessageEditPlugin.ts
│ │ │ │ ├── MessageHistoryPlugin.ts
│ │ │ │ ├── MessagePlugin.ts
│ │ │ │ ├── MessageSeenPlugin.ts
│ │ │ │ ├── RoomManagerPlugin.ts
│ │ │ │ └── TypingListPlugin.ts
│ │ ├── gallery
│ │ │ ├── Gallery.ts
│ │ │ ├── GalleryPlugin.ts
│ │ │ ├── GalleryPluginGroup.ts
│ │ │ ├── VideoConverterPlugin.ts
│ │ │ └── index.ts
│ │ ├── games
│ │ │ ├── GamesPluginGroup.ts
│ │ │ ├── global
│ │ │ │ ├── AprilFoolsDay.ts
│ │ │ │ ├── ConfusePlugin.ts
│ │ │ │ ├── CursorPlugin.ts
│ │ │ │ ├── MoneyFarmerPlugin.ts
│ │ │ │ ├── OfferMoneyPlugin.ts
│ │ │ │ └── SandalePlugin.ts
│ │ │ ├── index.ts
│ │ │ └── room
│ │ │ │ ├── DailyRollPlugin.ts
│ │ │ │ ├── GiveMoneyPlugin.ts
│ │ │ │ ├── GuessTheNumberPlugin.ts
│ │ │ │ ├── PointsCollectorPlugin.ts
│ │ │ │ ├── RacingPlugin.ts
│ │ │ │ ├── RandomGeneratorPlugin.ts
│ │ │ │ ├── RollPlugin.ts
│ │ │ │ ├── StatsPlugin.ts
│ │ │ │ └── UserPollPlugin.ts
│ │ ├── index.ts
│ │ ├── player
│ │ │ ├── PlayerChannel.ts
│ │ │ ├── PlayerChannelManager.ts
│ │ │ ├── PlayerChannelScheduler.ts
│ │ │ ├── PlayerPlugin.ts
│ │ │ ├── PlayerPluginGroup.ts
│ │ │ ├── YoutubeSearchAndPlayPlugin.ts
│ │ │ ├── fetcher
│ │ │ │ ├── GalleryFetcher.ts
│ │ │ │ ├── IFrameFetcher.ts
│ │ │ │ ├── TwitchFetcher.ts
│ │ │ │ ├── VideoFetcher.ts
│ │ │ │ └── YoutubeFetcher.ts
│ │ │ └── index.ts
│ │ ├── security_extra
│ │ │ ├── BunkerPlugin.ts
│ │ │ ├── ExtraSecurityPluginGroup.ts
│ │ │ ├── HistoryClearPlugin.ts
│ │ │ ├── LogFuzzerPlugin.ts
│ │ │ ├── MessageLimiterPlugin.ts
│ │ │ ├── RoomProtectPlugin.ts
│ │ │ ├── TorBanPlugin.ts
│ │ │ ├── TrackerPlugin.ts
│ │ │ ├── UsurpPlugin.ts
│ │ │ └── index.ts
│ │ └── user_defined
│ │ │ ├── .gitignore
│ │ │ ├── UserDefinedPluginGroup.ts
│ │ │ └── index.ts
│ ├── server.ts
│ └── skychat
│ │ ├── AuthBridge.ts
│ │ ├── Config.ts
│ │ ├── Connection.ts
│ │ ├── DatabaseHelper.ts
│ │ ├── FileManager.ts
│ │ ├── HttpServer.ts
│ │ ├── IBroadcaster.ts
│ │ ├── Logging.ts
│ │ ├── Message.ts
│ │ ├── MessageController.ts
│ │ ├── MessageFormatter.ts
│ │ ├── PluginManager.ts
│ │ ├── RandomGenerator.biguint-format.d.ts
│ │ ├── RandomGenerator.ts
│ │ ├── RateLimiter.ts
│ │ ├── Room.ts
│ │ ├── RoomManager.ts
│ │ ├── Session.ts
│ │ ├── ShellHelper.ts
│ │ ├── SkyChatServer.ts
│ │ ├── StickerManager.ts
│ │ ├── Timing.ts
│ │ ├── User.ts
│ │ └── UserController.ts
├── static
│ ├── Dockerfile
│ └── nginx.default.conf
├── template
│ ├── .env.template
│ ├── fakemessages.txt.template
│ ├── guestnames.txt.template
│ ├── preferences.json.template
│ ├── stickers.json.template
│ └── welcome.txt.template
└── traefik
│ └── dynamic.yml
├── babel.config.js
├── docker-compose.dev.yml
├── docker-compose.yml
├── package-lock.json
├── package.json
├── postcss.config.js
├── tailwind.config.cjs
├── tsconfig.json
└── vite.config.js
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 |
4 | # App directories
5 | backups
6 | gallery
7 | storage
8 | uploads
9 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | build
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true,
6 | "serviceworker": true
7 | },
8 | "root": true,
9 | "parser": "vue-eslint-parser",
10 | "parserOptions": {
11 | "parser": "@typescript-eslint/parser"
12 | },
13 | "extends": [
14 | "eslint:recommended",
15 | "plugin:vue/vue3-recommended",
16 | "prettier"
17 | ],
18 | "plugins": ["prettier"],
19 | "rules": {
20 | "prettier/prettier": [
21 | "error",
22 | {
23 | "singleQuote": true,
24 | "semi": true,
25 | "trailingComma": "all",
26 | "printWidth": 140,
27 | "tabWidth": 4,
28 | "useTabs": false,
29 | "bracketSpacing": true,
30 | "arrowParens": "always",
31 | "endOfLine": "auto"
32 | }
33 | ]
34 | },
35 | "globals": {
36 | "NodeJS": true
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/.github/workflows/.env.github:
--------------------------------------------------------------------------------
1 | # Password salt. Use something random, long and keep it secret.
2 | # Recommended: `tr -dc 'A-Za-z0-9!%&()*+,-./:;<=>?@[\]^_`{|}~' %4DUe5v~Zuc1L@|3g8*=kA(]b7YV_-
4 |
5 | # Token salt. Use something random, long and keep it secret.
6 | # Recommended: `tr -dc 'A-Za-z0-9!%&()*+,-./:;<=>?@[\]^_`{|}~' `
67 | MAILGUN_FROM=
68 |
69 | # Admin file browser
70 | ADMIN_FILEBROWSER_HOST=filebrowser.admin.skych.at.localhost
71 | ADMIN_FILEBROWSER_AUTH="" # Basic auth (`htpasswd -nb user password`)
72 |
73 | # Youtube API key. If unset, you will not be able to use the Youtube plugin (search for YouTube videos).
74 | # See [the guide](./app/doc/setup-youtube.md) to get a Youtube API key.
75 | YOUTUBE_API_KEY=
76 |
77 | # Enabled plugin groups. By default, all are enabled. To disable a plugin group, remove it from the list.
78 | ENABLED_PLUGINS="CorePluginGroup,GamesPluginGroup,ExtraSecurityPluginGroup,PlayerPluginGroup,GalleryPluginGroup,UserDefinedPluginGroup"
79 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker build CI
2 |
3 | on:
4 | push:
5 | branches: ['master']
6 | pull_request:
7 | branches: ['master']
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [14.x, 16.x, 18.x]
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'npm'
22 | - run: npm ci --ignore-scripts
23 | - run: npm run setup
24 | - run: echo $USER; id -u $USER; id -g $USER
25 | - run: cp .github/workflows/.env.github .env
26 | - run: docker compose up -d
27 |
--------------------------------------------------------------------------------
/.github/workflows/build-node.yml:
--------------------------------------------------------------------------------
1 | name: Build CI
2 |
3 | on:
4 | push:
5 | branches: ['master']
6 | pull_request:
7 | branches: ['master']
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [14.x, 16.x, 18.x]
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'npm'
22 | - run: npm ci --ignore-scripts
23 | - run: npm run setup
24 | - run: npm run build
25 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on:
4 | push:
5 | branches: ['master']
6 | pull_request:
7 | branches: ['master']
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [18.x]
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'npm'
22 | - run: npm ci --ignore-scripts
23 | - run: npm run lint
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 |
3 | backups
4 | build
5 | config
6 | dist
7 | node_modules
8 | storage
9 | uploads
10 |
11 | /gallery
12 |
13 | tsconfig.tsbuildinfo
14 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "trailingComma": "all",
5 | "printWidth": 140,
6 | "tabWidth": 4,
7 | "useTabs": false,
8 | "bracketSpacing": true,
9 | "arrowParens": "always",
10 | "endOfLine": "auto"
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": "always",
4 | "source.organizeImports": "always"
5 | },
6 | "eslint.validate": ["typescript", "vue", "html"]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 7PH
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 |
--------------------------------------------------------------------------------
/app/api/BinaryMessageTypes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Binary messages are prefixed with a binary header (UINT16)
3 | * specifying how to decode the incoming data.
4 | */
5 | export class BinaryMessageTypes {
6 | public static readonly AUDIO = 0x0002;
7 | public static readonly CURSOR = 0x0003;
8 | }
9 |
--------------------------------------------------------------------------------
/app/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from './BinaryMessageTypes.js';
2 | export * from './SkyChatClient.js';
3 |
--------------------------------------------------------------------------------
/app/api/plugins/MutePluginHelper.ts:
--------------------------------------------------------------------------------
1 | import { SkyChatClient } from '../SkyChatClient';
2 |
3 | export class MutePluginHelper {
4 | private readonly client: SkyChatClient;
5 |
6 | constructor(client: SkyChatClient) {
7 | this.client = client;
8 | }
9 |
10 | getMutedRooms(): number[] {
11 | return this.client.state.user.data.plugins.mute ?? [];
12 | }
13 |
14 | isRoomMuted(roomId: number): boolean {
15 | return this.getMutedRooms().includes(roomId);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # TODO: Remove
11 | dist
12 | dist-ssr
13 | *.local
14 |
--------------------------------------------------------------------------------
/app/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:24-alpine3.20 AS build
2 |
3 | WORKDIR /workdir
4 |
5 | ENV NODE_OPTIONS="--max-old-space-size=8192"
6 |
7 | COPY package*.json *config\.* ./
8 | COPY app ./app
9 |
10 | RUN npm ci --ignore-scripts
11 | RUN npm run build:client
12 |
13 | FROM nginxinc/nginx-unprivileged:alpine3.21-perl AS production
14 |
15 | EXPOSE 80
16 |
17 | COPY --from=build /workdir/dist /usr/share/nginx/html
18 |
19 | COPY app/client/nginx.default.conf /etc/nginx/conf.d/default.conf
20 |
21 | CMD ["nginx", "-g", "daemon off;"]
22 |
--------------------------------------------------------------------------------
/app/client/Dockerfile-dev:
--------------------------------------------------------------------------------
1 | FROM node:24-alpine3.20
2 | EXPOSE 80
3 | CMD ["npx", "vite"]
4 |
5 | ENV NODE_ENV=development
6 |
7 | WORKDIR /workdir
8 |
9 | COPY package*.json *config\.* ./
10 |
11 | RUN npm ci --ignore-scripts
12 |
--------------------------------------------------------------------------------
/app/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | ~ SkyChat
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/client/nginx.default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | # Deny access to hidden files (.htaccess, .git, etc.)
9 | location ~ /\. {
10 | deny all;
11 | access_log off;
12 | log_not_found off;
13 | }
14 |
15 | # Serve static files and fallback to index.html
16 | location / {
17 | try_files $uri $uri/ /index.html;
18 | }
19 |
20 | # Add basic security headers
21 | add_header X-XSS-Protection "1; mode=block" always;
22 | add_header Referrer-Policy "no-referrer-when-downgrade" always;
23 | add_header Content-Security-Policy "default-src 'self'; img-src 'self' https://risibank.fr; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none'; frame-src *;" always;
24 |
25 | client_max_body_size 1M;
26 | server_tokens off;
27 | }
28 |
--------------------------------------------------------------------------------
/app/client/public/assets/background-light.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/background-light.jpg
--------------------------------------------------------------------------------
/app/client/public/assets/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/background.png
--------------------------------------------------------------------------------
/app/client/public/assets/background.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/app/client/public/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/favicon.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/avatars/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/avatars/default.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/avatars/server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/avatars/server.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/cursors/animal.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/cursors/animal.gif
--------------------------------------------------------------------------------
/app/client/public/assets/images/cursors/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/cursors/default.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/icons/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/icons/loading.gif
--------------------------------------------------------------------------------
/app/client/public/assets/images/icons/risibank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/icons/risibank.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/icons/twitch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/icons/twitch.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/icons/video.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/icons/video.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/0_paper_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/0_paper_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/0_paper_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/0_paper_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/10_topaz_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/10_topaz_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/10_topaz_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/10_topaz_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/11_ruby_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/11_ruby_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/11_ruby_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/11_ruby_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/12_saphir_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/12_saphir_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/12_saphir_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/12_saphir_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/13_emerald_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/13_emerald_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/13_emerald_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/13_emerald_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/14_diamond_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/14_diamond_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/14_diamond_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/14_diamond_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/15_diamond_shiny_18.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/15_diamond_shiny_18.gif
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/15_diamond_shiny_26.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/15_diamond_shiny_26.gif
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/1_cardboard_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/1_cardboard_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/1_cardboard_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/1_cardboard_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/2_plastic_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/2_plastic_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/2_plastic_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/2_plastic_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/3_stone_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/3_stone_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/3_stone_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/3_stone_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/4_bronze_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/4_bronze_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/4_bronze_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/4_bronze_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/5_silver_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/5_silver_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/5_silver_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/5_silver_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/6_gold_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/6_gold_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/6_gold_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/6_gold_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/7_platinum_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/7_platinum_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/7_platinum_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/7_platinum_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/8_onyx_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/8_onyx_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/8_onyx_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/8_onyx_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/9_amethyst_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/9_amethyst_18.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/materials/9_amethyst_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/materials/9_amethyst_26.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/0_paper.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/0_paper.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/1_wood.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/1_wood.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/2_plastic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/2_plastic.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/3_bronze.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/3_bronze.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/4_silver.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/4_silver.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/5_gold.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/5_gold.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/6_saphir.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/6_saphir.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/7_ruby.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/7_ruby.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/8_emerald.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/8_emerald.png
--------------------------------------------------------------------------------
/app/client/public/assets/images/ranks/tongs/9_diamond.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/images/ranks/tongs/9_diamond.gif
--------------------------------------------------------------------------------
/app/client/public/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/logo.png
--------------------------------------------------------------------------------
/app/client/public/assets/sound/new-poll.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/sound/new-poll.ogg
--------------------------------------------------------------------------------
/app/client/public/assets/sound/notification.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/sound/notification.mp3
--------------------------------------------------------------------------------
/app/client/public/assets/sound/roll-end.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/sound/roll-end.ogg
--------------------------------------------------------------------------------
/app/client/public/assets/sound/roll.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/assets/sound/roll.ogg
--------------------------------------------------------------------------------
/app/client/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/public/favicon.png
--------------------------------------------------------------------------------
/app/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "~ SkyChat",
3 | "short_name": "SkyChat",
4 | "theme_color": "#212121",
5 | "background_color": "#212121",
6 | "description" : "Install the SkyChat in your homescreen",
7 | "start_url": "/",
8 | "display": "fullscreen"
9 | }
10 |
--------------------------------------------------------------------------------
/app/client/public/service-worker.js:
--------------------------------------------------------------------------------
1 | self.addEventListener('push', function (event) {
2 | let data = {};
3 | if (event.data) {
4 | data = event.data.json();
5 | }
6 |
7 | const title = data.title ?? 'SkyChat Notification';
8 | const options = {
9 | body: data.body ?? 'New notification from SkyChat',
10 | icon: '/favicon.png',
11 | };
12 |
13 | event.waitUntil(self.registration.showNotification(title, options));
14 | });
15 |
--------------------------------------------------------------------------------
/app/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/client/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/client/src/assets/logo.png
--------------------------------------------------------------------------------
/app/client/src/components/common/SkyDropdown.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/client/src/components/common/SkyDropdownItem.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/client/src/components/common/SkyTooltip.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/client/src/components/gallery/GalleryFileDotMenu.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
62 |
63 |
64 |
77 |
--------------------------------------------------------------------------------
/app/client/src/components/layout/AppHeader.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
68 |
69 |
70 |
78 |
--------------------------------------------------------------------------------
/app/client/src/components/message/MessageReaction.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
37 |
38 |
--------------------------------------------------------------------------------
/app/client/src/components/message/MessageReactionAdd.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/app/client/src/components/message/MessageReactions.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/GalleryModal.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | You do not have the permission to view the gallery.
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/ManageRoomsModal.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 | Manage rooms
31 |
32 |
33 |
34 | {{ element.name }}
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/ModalTemplate.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 | {{ title }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
53 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/OngoingConvertsModal.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {{ ongoingConvert.source.split('/').pop() }}
21 |
22 |
⬇
23 |
24 | {{ ongoingConvert.target.split('/').pop() }}
25 |
26 |
27 |
28 | {{ ongoingConvert.lastUpdate }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/PlayerQueueModal.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ entry.video.title }}
22 |
23 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/client/src/components/modal/ProfileModal.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 | Avatar
39 |
40 |
41 |
42 |
43 |
44 | Motto
45 |
46 |
47 |
48 |
49 |
50 |
51 | Custom color
52 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/app/client/src/components/player/MediaPlayer.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/client/src/components/player/impl/GalleryPlayer.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/client/src/components/player/impl/IFramePlayer.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/app/client/src/components/player/impl/TwitchPlayer.vue:
--------------------------------------------------------------------------------
1 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/client/src/components/player/impl/YoutubePlayer.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/app/client/src/components/playerchannel/PlayerChannelList.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
Media channels
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/client/src/components/playerchannel/SinglePlayerChannel.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
45 |
46 |
47 |
48 | {{ playerChannel.name }}
49 |
50 |
51 |
52 |
53 |
57 | {{ playerChannel.currentMedia.title }}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/app/client/src/components/poll/PollList.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
18 |
19 | {{ poll.title }}: {{ poll.content }}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
33 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/client/src/components/room/RoomList.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
30 | Rooms
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/app/client/src/components/user/ConnectedList.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
Active now
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/client/src/components/user/UserBigAvatar.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
18 |
![]()
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/app/client/src/components/user/UserMiniAvatar.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
18 |
![]()
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/app/client/src/components/user/UserMiniAvatarCollection.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/client/src/components/util/ExpandableBlock.vue:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
48 |
49 |
50 |
53 |
54 |
55 |
56 |
57 |
69 |
--------------------------------------------------------------------------------
/app/client/src/components/util/HoverCard.vue:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
54 |
55 |
56 |
88 |
--------------------------------------------------------------------------------
/app/client/src/components/util/SectionSubTitle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/client/src/components/util/SectionTitle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/client/src/composables/useClientState.js:
--------------------------------------------------------------------------------
1 | import { useClientStore } from '@/stores/client.js';
2 | import { watch } from 'vue';
3 |
4 | /**
5 | * @param {Function} callback What to do when the client state changes
6 | */
7 | export function useClientState(callback) {
8 | const client = useClientStore();
9 |
10 | watch(() => client.state, callback, { deep: true });
11 | }
12 |
--------------------------------------------------------------------------------
/app/client/src/css/index.css:
--------------------------------------------------------------------------------
1 | @import './layout.css';
2 | @import './modal.css';
3 | @import './scrollbar.css';
4 | @import './skychat.css';
5 | @import './util.css';
6 | @import './lib.css';
7 |
8 | @tailwind base;
9 | @tailwind components;
10 | @tailwind utilities;
11 |
12 | @layer base {
13 | :root {
14 | --page-header-height: 4rem;
15 | --page-max-width: 1640px;
16 | --page-col-left-width: 280px;
17 | --page-col-right-width: 340px;
18 | --modal-width: 400px;
19 |
20 | --color-skygray-white: 244 244 255;
21 | --color-skygray-lightest: 160 160 160;
22 | --color-skygray-lighter: 115 115 115;
23 | --color-skygray-light: 84 84 88;
24 | --color-skygray-casual: 61 61 61;
25 | --color-skygray-dark: 40 40 40;
26 | --color-skygray-darker: 17 17 17;
27 | --color-skygray-black: 0 0 3;
28 |
29 | --color-primary: 119 155 233;
30 | --color-primary-light: 182 200 244;
31 | --color-secondary: 161 111 218;
32 | --color-secondary-light: 211 173 255;
33 | --color-tertiary: 250 88 182;
34 | --color-tertiary-light: 247 166 219;
35 |
36 | --color-info: 119 155 233;
37 | --color-info-light: 182 200 244;
38 | --color-warn: 228 159 77;
39 | --color-warn-light: 244 175 125;
40 | --color-danger: 255 69 103;
41 | --color-danger-light: 255 120 150;
42 |
43 | --color-scrollbar-track: #34343422;
44 | --color-scrollbar-thumb: rgba(78, 78, 78, 0.5);
45 | --color-scrollbar-thumb-hover: #888;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/client/src/css/layout.css:
--------------------------------------------------------------------------------
1 | body {
2 | @apply relative;
3 | color: white;
4 | background: black;
5 | height: 100%;
6 | height: 100vh;
7 | overflow: hidden;
8 |
9 | font-family: 'Lato', sans-serif;
10 |
11 | background-image: url('/assets/background-light.jpg');
12 | background-size: cover;
13 | }
14 |
15 | #app {
16 | height: 100%;
17 | }
18 |
--------------------------------------------------------------------------------
/app/client/src/css/lib.css:
--------------------------------------------------------------------------------
1 | .mention-item {
2 | padding: 4px 10px;
3 | border-radius: 4px;
4 | }
5 |
6 | .mention-selected {
7 | background: rgb(170, 170, 170);
8 | }
9 |
--------------------------------------------------------------------------------
/app/client/src/css/modal.css:
--------------------------------------------------------------------------------
1 | #modals {
2 | @apply fixed top-0 left-0 w-full h-full mt-[var(--page-header-height)];
3 | @apply pointer-events-none;
4 | }
5 |
6 | #modals #modal-container {
7 | @apply relative h-[calc(100vh-var(--page-header-height))] mx-auto w-full max-w-[var(--page-max-width)] transition-all;
8 | }
9 |
10 | #modals #modal-container:not(:empty) {
11 | @apply backdrop-grayscale backdrop-blur-xl;
12 | @apply pointer-events-auto;
13 | }
14 |
15 | #modals #modal-container .modal {
16 | @apply transition-all absolute top-0 right-0 bg-secondary/10 backdrop-blur-2xl p-4;
17 | @apply h-full w-full lg:w-[var(--modal-width)];
18 | @apply pointer-events-auto;
19 | }
20 |
21 | #modals #modal-container .modal:nth-child(1) { z-index: 29; }
22 | #modals #modal-container .modal:nth-child(2) { z-index: 28; }
23 | #modals #modal-container .modal:nth-child(3) { z-index: 27; }
24 | #modals #modal-container .modal:nth-child(4) { z-index: 26; }
25 | #modals #modal-container .modal:nth-child(5) { z-index: 25; }
26 | #modals #modal-container .modal:nth-child(6) { z-index: 24; }
27 | #modals #modal-container .modal:nth-child(7) { z-index: 23; }
28 | #modals #modal-container .modal:nth-child(8) { z-index: 22; }
29 | #modals #modal-container .modal:nth-child(9) { z-index: 21; }
30 |
31 | #modals #modal-container .modal:not(:nth-child(1)) {
32 | opacity: 0;
33 | }
34 |
--------------------------------------------------------------------------------
/app/client/src/css/scrollbar.css:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | /** Firefox */
5 | .scrollbar {
6 | scrollbar-color: transparent transparent;
7 | scrollbar-width: thin;
8 | }
9 | .scrollbar:hover {
10 | scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track);
11 | }
12 |
13 | /** Chrome */
14 | .scrollbar::-webkit-scrollbar { width: 0.5rem; }
15 | .scrollbar::-webkit-scrollbar-track { background: transparent; }
16 | .scrollbar::-webkit-scrollbar-thumb { background: transparent; }
17 | .scrollbar::-webkit-scrollbar-thumb:hover { background: transparent; }
18 | .scrollbar:hover::-webkit-scrollbar-track { background: var(--color-scrollbar-track); }
19 | .scrollbar:hover::-webkit-scrollbar-thumb { background: var(--color-scrollbar-thumb); }
20 | .scrollbar:hover::-webkit-scrollbar-thumb:hover { background: var(--color-scrollbar-thumb-hover); }
21 | @media (hover: hover) {
22 | .scrollbar:hover::-webkit-scrollbar-track { background: var(--color-scrollbar-track); }
23 | .scrollbar:hover::-webkit-scrollbar-thumb { background: var(--color-scrollbar-thumb); }
24 | .scrollbar:hover::-webkit-scrollbar-thumb:hover { background: var(--color-scrollbar-thumb-hover); }
25 | }
26 |
--------------------------------------------------------------------------------
/app/client/src/css/skychat.css:
--------------------------------------------------------------------------------
1 | /* Sticker */
2 | .skychat-sticker {
3 | @apply inline-block align-baseline;
4 | max-width: 80px;
5 | max-height: 80px;
6 | }
7 |
8 | /* RisiBank Sticker */
9 | .skychat-risibank-sticker {
10 | @apply inline-block align-baseline;
11 | width: 90px;
12 | height: 60px;
13 | }
14 | .skychat-risibank-sticker > img {
15 | object-fit: contain;
16 | height: 100%;
17 | width: 100%;
18 | }
19 |
20 | /* Image */
21 | .skychat-image {
22 | @apply inline-block align-baseline;
23 | width: 120px;
24 | height: 80px;
25 | }
26 | .skychat-image > img {
27 | object-fit: contain;
28 | height: 100%;
29 | width: 100%;
30 | }
31 |
32 | /* Audio */
33 | .skychat-audio-tag {
34 | @apply inline;
35 | height: 34px;
36 | }
37 |
38 | .skychat-button {
39 | padding: 2px 10px;
40 | border-radius: 10px;
41 | background-color: transparent;
42 | color: white;
43 | border: 1px solid #777777;
44 | cursor: pointer;
45 | }
46 | .skychat-button .skychat-button-info {
47 | color: #f3aaaa;
48 | }
49 |
50 | .skychat-link {
51 | text-decoration: underline;
52 | color: white;
53 | }
54 |
55 | .skychat-table {
56 | text-align: left;
57 | width: 100%;
58 | border-collapse: collapse;
59 | }
60 | .skychat-table tr {
61 | border-bottom: 1pt solid #4e4e4e;
62 | }
63 | .skychat-table tr td,
64 | .skychat-table tr th {
65 | padding: 2px;
66 | }
67 |
68 | .skychat-quote {
69 | cursor: pointer;
70 | background-color: #303782;
71 | padding: 5px 6px;
72 | border-radius: 10px;
73 | margin: 0 4px;
74 | }
75 | .skychat-quote:hover {
76 | background-color: #5865f2;
77 | }
78 |
--------------------------------------------------------------------------------
/app/client/src/css/util.css:
--------------------------------------------------------------------------------
1 |
2 | /* Form element */
3 | .form-control {
4 | @apply inline-block rounded-2xl bg-skygray-darker/50 hover:bg-skygray-casual/25 px-4 py-2 placeholder-skygray-lighter text-skygray-white;
5 | @apply transition-all;
6 | }
7 |
8 | /* Button */
9 | .btn {
10 | @apply rounded-lg px-2 py-1 border-2 border-skygray-light bg-skygray-casual/25 hover:bg-skygray-light/25 transition;
11 | }
12 | .btn.active {
13 | @apply bg-skygray-light/50;
14 | }
15 | .btn.disabled {
16 | @apply pointer-events-none text-skygray-dark/50 bg-skygray-dark/25;
17 | }
18 |
19 | /* Button group */
20 | .btn-group {
21 | @apply border-2 border-skygray-light/50 rounded-lg flex overflow-hidden;
22 | }
23 | .btn-group > .btn {
24 | @apply border-0 rounded-none grow;
25 | }
26 | .btn-group > .btn:nth-child(1) {
27 | }
28 | .btn-group > .btn:not(:nth-last-child(1)) {
29 | @apply border-r-2 border-skygray-casual/25;
30 | }
31 | .btn-group > .btn:nth-last-child(1) {
32 | }
33 |
--------------------------------------------------------------------------------
/app/client/src/icons.js:
--------------------------------------------------------------------------------
1 | import { library } from '@fortawesome/fontawesome-svg-core';
2 | import {
3 | faArrowLeft,
4 | faArrowPointer,
5 | faArrowRightFromBracket,
6 | faArrowUpRightFromSquare,
7 | faBan,
8 | faBell,
9 | faCaretLeft,
10 | faCaretRight,
11 | faChevronDown,
12 | faChevronLeft,
13 | faChevronRight,
14 | faChevronUp,
15 | faCircleDot,
16 | faClock,
17 | faClosedCaptioning,
18 | faComments,
19 | faCompress,
20 | faCopy,
21 | faDownLeftAndUpRightToCenter,
22 | faEgg,
23 | faEllipsis,
24 | faEllipsisVertical,
25 | faExpand,
26 | faFile,
27 | faFileVideo,
28 | faFolder,
29 | faFolderTree,
30 | faForwardStep,
31 | faGear,
32 | faGears,
33 | faGlobe,
34 | faImage,
35 | faInfo,
36 | faKey,
37 | faLinkSlash,
38 | faList,
39 | faLock,
40 | faMicrophone,
41 | faMinus,
42 | faMobileScreen,
43 | faMusic,
44 | faPaperPlane,
45 | faPause,
46 | faPenToSquare,
47 | faPlay,
48 | faPlus,
49 | faPowerOff,
50 | faRotate,
51 | faSlash,
52 | faSpinner,
53 | faThumbsDown,
54 | faThumbsUp,
55 | faToggleOff,
56 | faToggleOn,
57 | faTv,
58 | faUpload,
59 | faUpRightAndDownLeftFromCenter,
60 | faUser,
61 | faUsers,
62 | faVideo,
63 | faVolumeXmark,
64 | faXmark,
65 | } from '@fortawesome/free-solid-svg-icons';
66 |
67 | library.add(
68 | faArrowLeft,
69 | faArrowPointer,
70 | faArrowRightFromBracket,
71 | faArrowUpRightFromSquare,
72 | faBan,
73 | faBell,
74 | faCaretLeft,
75 | faCaretRight,
76 | faChevronDown,
77 | faChevronLeft,
78 | faChevronRight,
79 | faChevronUp,
80 | faCircleDot,
81 | faClock,
82 | faClosedCaptioning,
83 | faComments,
84 | faCompress,
85 | faCopy,
86 | faDownLeftAndUpRightToCenter,
87 | faEgg,
88 | faEllipsis,
89 | faEllipsisVertical,
90 | faExpand,
91 | faFile,
92 | faFileVideo,
93 | faFolder,
94 | faFolderTree,
95 | faForwardStep,
96 | faGear,
97 | faGears,
98 | faGlobe,
99 | faImage,
100 | faInfo,
101 | faKey,
102 | faLinkSlash,
103 | faList,
104 | faLock,
105 | faMicrophone,
106 | faMinus,
107 | faMobileScreen,
108 | faMusic,
109 | faPaperPlane,
110 | faPause,
111 | faPenToSquare,
112 | faPlay,
113 | faPlus,
114 | faPowerOff,
115 | faRotate,
116 | faSlash,
117 | faSpinner,
118 | faThumbsDown,
119 | faThumbsUp,
120 | faToggleOff,
121 | faToggleOn,
122 | faTv,
123 | faUpload,
124 | faUpRightAndDownLeftFromCenter,
125 | faUser,
126 | faUsers,
127 | faVideo,
128 | faVolumeXmark,
129 | faXmark,
130 | );
131 |
132 | export default library;
133 |
--------------------------------------------------------------------------------
/app/client/src/lib/AudioRecorder.js:
--------------------------------------------------------------------------------
1 | export class AudioRecorder {
2 | static async start() {
3 | // Keeps track of all the audio chunks
4 | const chunks = [];
5 |
6 | // Start recording
7 | const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
8 | const mediaRecorder = new MediaRecorder(mediaStream);
9 | mediaRecorder.addEventListener('dataavailable', (event) => chunks.push(event.data));
10 | mediaRecorder.start();
11 |
12 | // Stop callback
13 | const stop = () => {
14 | return new Promise((resolve) => {
15 | // Create the blob/uri/audio once the recording actually stops
16 | mediaRecorder.addEventListener('stop', () => {
17 | const blob = new Blob(chunks);
18 | const uri = URL.createObjectURL(blob);
19 | const audio = new Audio(uri);
20 | mediaStream.getTracks().forEach((t) => t.stop());
21 | resolve({ blob, uri, audio });
22 | });
23 |
24 | // Stop the recording
25 | mediaRecorder.stop();
26 | });
27 | };
28 |
29 | return stop;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/client/src/lib/Gallery.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * @param {string[]} folderList List of folders to go to the file
4 | * @param {*} fileName File name
5 | * @returns
6 | */
7 | const getFileNamePath = (folderList, fileName) => {
8 | return folderList.length === 0 ? fileName : `${folderList.join('/')}/${fileName}`;
9 | };
10 |
11 | const isFileTypeAddable = (fileType) => {
12 | return fileType === 'video';
13 | };
14 |
15 | const getFileExtension = (fileName) => {
16 | return fileName.split('.').pop();
17 | };
18 |
19 | const getFileIcon = (file) => {
20 | return (
21 | {
22 | video: 'video',
23 | subtitle: 'closed-captioning',
24 | audio: 'music',
25 | image: 'image',
26 | unknown: 'file',
27 | }[file.type] || 'file'
28 | );
29 | };
30 | const getFileColor = (file) => {
31 | return (
32 | {
33 | video: 'rgb(var(--color-tertiary))',
34 | subtitle: 'rgb(var(--color-tertiary-light))',
35 | audio: 'rgb(var(--color-secondary))',
36 | image: 'rgb(var(--color-primary))',
37 | }[file.type] || 'rgb(var(--color-skygray-lightest))'
38 | );
39 | };
40 |
41 | export const useGallery = () => {
42 | return {
43 | getFileNamePath,
44 | isFileTypeAddable,
45 | getFileExtension,
46 | getFileIcon,
47 | getFileColor,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/app/client/src/lib/WebPush.js:
--------------------------------------------------------------------------------
1 | export class WebPush {
2 | static SERVICE_WORKER_URL = 'service-worker.js';
3 |
4 | static urlBase64ToUint8Array(base64String) {
5 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
6 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
7 | const rawData = window.atob(base64);
8 | const outputArray = new Uint8Array(rawData.length);
9 | for (let i = 0; i < rawData.length; ++i) {
10 | outputArray[i] = rawData.charCodeAt(i);
11 | }
12 | return outputArray;
13 | }
14 |
15 | static async register(vapidPublicKey) {
16 | if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
17 | throw new Error('WebPush is not supported');
18 | }
19 |
20 | const registration = await navigator.serviceWorker.register(WebPush.SERVICE_WORKER_URL);
21 | const permission = await Notification.requestPermission();
22 |
23 | const subscription = await registration.pushManager.getSubscription();
24 | if (subscription) {
25 | return null;
26 | }
27 |
28 | if (permission !== 'granted') {
29 | throw new Error('Permission denied');
30 | }
31 |
32 | const convertedVapidKey = WebPush.urlBase64ToUint8Array(vapidPublicKey);
33 |
34 | return registration.pushManager.subscribe({
35 | userVisibleOnly: true,
36 | applicationServerKey: convertedVapidKey,
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/client/src/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue';
2 | import { createPinia } from 'pinia';
3 | import { createRouter, createWebHashHistory } from 'vue-router';
4 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
5 | import mousetrap from 'mousetrap';
6 |
7 | import Toast from 'vue-toastification';
8 | import 'vue-toastification/dist/index.css';
9 |
10 | import 'vue-smart-suggest/lib/style.css';
11 |
12 | import App from './App.vue';
13 | import Home from '@/views/Home.vue';
14 | import Chat from '@/views/Chat.vue';
15 | import { useAppStore } from '@/stores/app';
16 |
17 | import './icons';
18 | import './css/index.css';
19 |
20 | (async () => {
21 | const app = createApp(App);
22 |
23 | // Init router
24 | const router = createRouter({
25 | history: createWebHashHistory(),
26 | routes: [
27 | { name: 'home', path: '/', component: Home },
28 | { name: 'app', path: '/app', component: Chat },
29 | ],
30 | });
31 |
32 | // Init stores
33 | app.use(createPinia());
34 | const appStore = useAppStore();
35 | appStore.init();
36 |
37 | // Font Awesome
38 | // eslint-disable-next-line vue/multi-word-component-names
39 | app.component('Fa', FontAwesomeIcon);
40 |
41 | // Use router
42 | app.use(router);
43 |
44 | // Mousetrap
45 | app.config.globalProperties.$mousetrap = mousetrap;
46 | app.provide('mousetrap', mousetrap);
47 |
48 | // Toast
49 | app.use(Toast, { position: 'top-center' });
50 |
51 | // Better way of doing this?
52 | router.push('/');
53 |
54 | // Mount app
55 | app.mount('#app');
56 | })();
57 |
--------------------------------------------------------------------------------
/app/database/.gitignore:
--------------------------------------------------------------------------------
1 | data
2 |
--------------------------------------------------------------------------------
/app/database/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM postgres:16-alpine
2 | CMD ["postgres"]
3 |
4 | ARG DOCKER_USER
5 | ARG DOCKER_UID
6 | ARG DOCKER_GID
7 |
8 | # Change workdir
9 | WORKDIR /app
10 |
11 | # Create a non-root user and group with the specified UID and GID
12 | RUN addgroup -g "$DOCKER_GID" "$DOCKER_USER" && \
13 | adduser -u "$DOCKER_UID" -G "$DOCKER_USER" -D "$DOCKER_USER" && \
14 | chown -R "$DOCKER_USER:$DOCKER_USER" .
15 |
16 | USER $DOCKER_UID:$DOCKER_GID
17 |
--------------------------------------------------------------------------------
/app/doc/screenshot-desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/doc/screenshot-desktop.png
--------------------------------------------------------------------------------
/app/doc/screenshot-mobile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/doc/screenshot-mobile.png
--------------------------------------------------------------------------------
/app/doc/screenshot-ui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skychatorg/skychat/8d71738106addf3ec2363597530adb96ad7d8cd8/app/doc/screenshot-ui.png
--------------------------------------------------------------------------------
/app/doc/setup-youtube.md:
--------------------------------------------------------------------------------
1 | # Setup Youtube
2 |
3 | The SkyChat requires an API key for the Youtube plugin to work. This key needs to be put in your `.env` file.
4 |
5 | Using the Youtube API is free. There is a daily quota which when exceeded blocks requests until the next day. If it happens, the Youtube plugin won't work until the next day.
6 |
7 | ## How to generate
8 |
9 | 1. Go to [the Google Cloud Platform](https://console.cloud.google.com/apis/api/youtube.googleapis.com/credentials). If you never activated the account, you will have to activate it.
10 | 2. Click `Create credentials > API key`
11 | 3. Copy the generated API key, and paste it in your `.env` file (`YOUTUBE_API_KEY`)
12 | 4. Restart the server
13 |
--------------------------------------------------------------------------------
/app/filebrowser/.gitignore:
--------------------------------------------------------------------------------
1 | /data
2 |
--------------------------------------------------------------------------------
/app/filebrowser/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:3.21.3
2 | EXPOSE 80
3 | CMD sh start.sh
4 |
5 | ARG DOCKER_USER
6 | ARG DOCKER_UID
7 | ARG DOCKER_GID
8 |
9 | WORKDIR /app
10 |
11 | # Install filebrowser
12 | RUN apk add --no-cache --update curl bash && \
13 | curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash && \
14 | addgroup -g "$DOCKER_GID" "$DOCKER_USER" && \
15 | adduser -u "$DOCKER_UID" -G "$DOCKER_USER" -D "$DOCKER_USER" && \
16 | mkdir files && \
17 | ln -s /mnt/skychat/gallery ./files/gallery && \
18 | ln -s /mnt/skychat/uploads ./files/uploads && \
19 | chown "$DOCKER_UID:$DOCKER_GID" /app
20 |
21 | COPY --chown="$DOCKER_UID:$DOCKER_GID" settings.json /config/settings.json
22 | COPY --chown="$DOCKER_UID:$DOCKER_GID" start.sh /app/start.sh
23 |
24 | # Change user
25 | USER $DOCKER_UID:$DOCKER_GID
26 |
--------------------------------------------------------------------------------
/app/filebrowser/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "port": 80,
3 | "baseURL": "",
4 | "address": "",
5 | "log": "stdout",
6 | "database": "/app/filebrowser.db",
7 | "root": "/"
8 | }
9 |
--------------------------------------------------------------------------------
/app/filebrowser/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Refuse to start if enabled but without http auth (ADMIN_FILEBROWSER_AUTH is empty)
4 | if [ -n "$ADMIN_FILEBROWSER_ENABLED" ] && [ -z "$ADMIN_FILEBROWSER_AUTH" ]; then
5 | echo "⚠️ Filebrowser is enabled but no authentication method is set. Please set ADMIN_FILEBROWSER_AUTH."
6 | exit 1
7 | fi
8 |
9 | filebrowser config set --auth.method=noauth
10 | filebrowser -a 0.0.0.0 -p 80 -b /filebrowser -r files -c /config/settings.json
11 |
--------------------------------------------------------------------------------
/app/script/autoinstall.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # 1. Clone the repository
4 | git clone https://github.com/skychatorg/skychat.git
5 | cd skychat
6 |
7 | # 2. Setup necessary files
8 | sh app/script/setup.sh
9 |
--------------------------------------------------------------------------------
/app/script/backup.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Constants
4 | BACKUP_DIRS="config storage uploads/avatars uploads/stickers"
5 | BACKUP_LOCATION="backups"
6 |
7 | # Do backup
8 | BACKUP_FILENAME=$(date +%F-%H-%M-%S-%N)
9 | BACKUP_FILEPATH="$BACKUP_LOCATION/$BACKUP_FILENAME.zip"
10 |
11 | # Create file dir & make backup
12 | zip -r $BACKUP_FILEPATH $BACKUP_DIRS > /dev/null
13 |
14 | # Echo new backup filepath
15 | echo $BACKUP_FILEPATH
16 |
--------------------------------------------------------------------------------
/app/script/debug-db.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source .env
4 |
5 | DOCKER_CONTAINER_ID=$(docker ps | grep skychat_db | awk '{print $1}')
6 |
7 | docker exec -it "$DOCKER_CONTAINER_ID" /usr/local/bin/psql "--user=$POSTGRES_USER" "$POSTGRES_DB"
8 |
--------------------------------------------------------------------------------
/app/script/debug-log.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Log web server
4 | docker container logs -n 1000 -f $(docker container list | grep skychat_backend | cut -d' ' -f1) | pino-pretty
5 |
--------------------------------------------------------------------------------
/app/script/dev.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
4 |
5 |
--------------------------------------------------------------------------------
/app/script/reset.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Remove all configuration files
4 | rm -f .env
5 |
6 | # Clear storage
7 | rm -rf backups build config dist gallery storage uploads
8 |
--------------------------------------------------------------------------------
/app/script/setup.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Create empty directories if they do not exist
4 | if [ ! -e backups ]; then
5 | mkdir backups;
6 | fi
7 | if [ ! -e uploads ]; then
8 | mkdir -p uploads/{all,avatars,stickers};
9 | fi
10 | if [ ! -e gallery ]; then
11 | mkdir -p gallery;
12 | fi
13 | if [ ! -e storage ]; then
14 | mkdir -p storage;
15 | fi
16 | if [ ! -e storage/plugins ]; then
17 | mkdir -p storage/plugins;
18 | fi
19 | if [ ! -e storage/rooms ]; then
20 | mkdir -p storage/rooms;
21 | fi
22 | if [ ! -e app/database/data ]; then
23 | mkdir -p app/database/data;
24 | fi
25 | if [ ! -e app/filebrowser/data ]; then
26 | mkdir -p app/filebrowser/data;
27 | touch app/filebrowser/data/filebrowser.db;
28 | fi
29 |
30 | # Initialize .env
31 | if [ ! -e .env ]; then
32 | cp app/template/.env.template .env;
33 |
34 | DOCKER_USER="$USER"
35 | sed -i "0,/\$DOCKER_USER/{s/\$DOCKER_USER/$DOCKER_USER/}" .env
36 |
37 | DOCKER_UID="$(id -u)"
38 | sed -i "0,/\$DOCKER_UID/{s/\$DOCKER_UID/$DOCKER_UID/}" .env
39 |
40 | DOCKER_GID="$(id -g)"
41 | sed -i "0,/\$DOCKER_GID/{s/\$DOCKER_GID/$DOCKER_GID/}" .env
42 | fi
43 |
44 | # Create config directory if it does not exist
45 | if [ ! -e config ]; then
46 | mkdir config;
47 | fi
48 |
49 | # Initialize stickers.json
50 | if [ ! -e config/stickers.json ]; then
51 | cp app/template/stickers.json.template config/stickers.json;
52 | fi
53 |
54 | # Initialize preferences.json
55 | if [ ! -e config/preferences.json ]; then
56 | cp app/template/preferences.json.template config/preferences.json;
57 | fi
58 |
59 | # Initialize guest names list file
60 | if [ ! -e config/guestnames.txt ]; then
61 | cp app/template/guestnames.txt.template config/guestnames.txt;
62 | fi
63 |
64 | # Initialize fake messages list file
65 | if [ ! -e config/fakemessages.txt ]; then
66 | cp app/template/fakemessages.txt.template config/fakemessages.txt;
67 | fi
68 |
69 | # Initialize welcome.txt.template
70 | if [ ! -e config/welcome.txt.template ]; then
71 | cp app/template/welcome.txt.template config/welcome.txt;
72 | fi
73 |
--------------------------------------------------------------------------------
/app/script/start-server.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | node build/server/server.js | npx pino-pretty
4 |
--------------------------------------------------------------------------------
/app/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest
2 | EXPOSE $PUBLIC_PORT
3 | CMD ["sh", "app/script/start-server.sh"]
4 |
5 |
6 | ARG DOCKER_USER
7 | ARG DOCKER_UID
8 | ARG DOCKER_GID
9 | ARG DOCKER_TZ
10 |
11 | ARG PUBLIC_PORT
12 |
13 | WORKDIR /workdir
14 |
15 | # Needed to avoid "JavaScript heap out of memory" error
16 | ENV NODE_OPTIONS="--max-old-space-size=8192"
17 |
18 | # 1. Set timezone
19 | # 2. Create local user matching the host user
20 | # 3. Install SkyChat dependencies
21 | RUN ln -snf "/usr/share/zoneinfo/$DOCKER_TZ" /etc/localtime && \
22 | echo "$DOCKER_TZ" > /etc/timezone && \
23 | addgroup -g "$DOCKER_GID" "$DOCKER_USER" && \
24 | adduser -u "$DOCKER_UID" -G "$DOCKER_USER" -D "$DOCKER_USER" && \
25 | apk add --no-cache --update ffmpeg nodejs npm zip imagemagick
26 |
27 | COPY package*.json *config\.* ./
28 |
29 | # We need to chown the workdir so we can create node_modules in the last step
30 | RUN chown "$DOCKER_UID:$DOCKER_GID" /workdir
31 |
32 | USER $DOCKER_UID:$DOCKER_GID
33 |
34 | COPY app app
35 |
36 | RUN npm ci --ignore-scripts
37 |
38 | RUN npm run build:server
39 |
--------------------------------------------------------------------------------
/app/server/Dockerfile-dev:
--------------------------------------------------------------------------------
1 | FROM alpine:latest
2 | CMD npx nodemon --watch app/api/ --watch app/server/ -e js,ts,json,vue --exec "sh -c 'npm run build && sh app/script/start-server.sh'"
3 | EXPOSE 80
4 |
5 | # Define build-time arguments
6 | ARG DOCKER_USER
7 | ARG DOCKER_UID
8 | ARG DOCKER_GID
9 | ARG DOCKER_TZ
10 | ARG PUBLIC_PORT
11 |
12 | WORKDIR /workdir
13 |
14 | # Avoid "JavaScript heap out of memory"
15 | ENV NODE_OPTIONS="--max-old-space-size=8192"
16 |
17 | # Set timezone, create user, install dependencies
18 | RUN ln -snf "/usr/share/zoneinfo/${DOCKER_TZ}" /etc/localtime && \
19 | echo "${DOCKER_TZ}" > /etc/timezone && \
20 | addgroup -g "${DOCKER_GID}" "${DOCKER_USER}" && \
21 | adduser -u "${DOCKER_UID}" -G "${DOCKER_USER}" -D "${DOCKER_USER}" && \
22 | apk add --no-cache --update ffmpeg nodejs npm zip
23 |
24 | # Copy package files and configs
25 | COPY ["package.json", "package-lock.json", "*config.*", "./"]
26 |
27 | # Fix permissions
28 | RUN chown "${DOCKER_UID}:${DOCKER_GID}" /workdir
29 |
30 | # Switch to non-root user
31 | USER ${DOCKER_UID}:${DOCKER_GID}
32 |
33 | # Install dependencies
34 | RUN npm ci --ignore-scripts
35 |
--------------------------------------------------------------------------------
/app/server/cli.ts:
--------------------------------------------------------------------------------
1 | import { DatabaseHelper } from './skychat/DatabaseHelper.js';
2 | import { Logging } from './skychat/Logging.js';
3 |
4 | /**
5 | * Import a file in the gallery
6 | */
7 | export async function importFileToGallery() {
8 | throw new Error('Not implemented');
9 | }
10 |
11 | /**
12 | * Possible actions
13 | */
14 | const ACTIONS: { [action: string]: { argCount: number; handler: (...args: string[]) => Promise; usage: string } } = {
15 | 'file-import': {
16 | argCount: 1,
17 | handler: importFileToGallery,
18 | usage: '{filePath}',
19 | },
20 | };
21 |
22 | /**
23 | * Main entry point
24 | * @returns
25 | */
26 | export async function main() {
27 | await DatabaseHelper.load();
28 |
29 | const [action, ...args] = process.argv.slice(2);
30 |
31 | if (typeof ACTIONS[action] === 'undefined') {
32 | throw new Error('Action does not exist');
33 | }
34 |
35 | const { argCount, handler, usage } = ACTIONS[action];
36 |
37 | if (args.length !== argCount) {
38 | Logging.warn(`Usage:\ncli.js ${action} ${usage}`);
39 | return;
40 | }
41 |
42 | handler(...args);
43 | }
44 |
45 | main();
46 |
--------------------------------------------------------------------------------
/app/server/constants.ts:
--------------------------------------------------------------------------------
1 | export const WS_CLOSE_CODE_ERROR = 4001;
2 |
--------------------------------------------------------------------------------
/app/server/index.ts:
--------------------------------------------------------------------------------
1 | export type { CustomizationElements } from './plugins/core/global/CustomizationPlugin.js';
2 | export type { PollState, SanitizedPoll } from './plugins/core/global/Poll.js';
3 | export type { MessageSeenEventData } from './plugins/core/room/MessageSeenPlugin.js';
4 | export type { FolderContent } from './plugins/gallery/Gallery.js';
5 | export type { OngoingConvert, VideoStreamInfo } from './plugins/gallery/VideoConverterPlugin.js';
6 | export type { QueuedVideoInfo, SanitizedPlayerChannel, VideoInfo } from './plugins/player/PlayerChannel.js';
7 | export type { AuthData } from './skychat/AuthBridge.js';
8 | export type { PublicConfig } from './skychat/Config.js';
9 | export type { SanitizedMessage } from './skychat/Message.js';
10 | export type { SanitizedRoom } from './skychat/Room.js';
11 | export type { SanitizedSession } from './skychat/Session.js';
12 | export type { AuthToken, SanitizedUser, UserData } from './skychat/User.js';
13 |
--------------------------------------------------------------------------------
/app/server/plugins/GlobalPlugin.ts:
--------------------------------------------------------------------------------
1 | import { ConnectionAcceptedEvent } from '../skychat/AuthBridge.js';
2 | import { Connection } from '../skychat/Connection.js';
3 | import { RoomManager } from '../skychat/RoomManager.js';
4 | import { Plugin } from './Plugin.js';
5 |
6 | /**
7 | * A global plugin is a plugin which instantied once at the level of the room manager
8 | */
9 | export abstract class GlobalPlugin extends Plugin {
10 | public static readonly isGlobal: boolean = true;
11 |
12 | /**
13 | * Reference to the room manager
14 | */
15 | public readonly manager: RoomManager;
16 |
17 | /**
18 | * A globally instantiated plugin
19 | */
20 | constructor(manager: RoomManager) {
21 | super();
22 | this.manager = manager;
23 | }
24 |
25 | /**
26 | * Storage path for global plugins
27 | */
28 | public getStoragePath(): string {
29 | return `${Plugin.STORAGE_BASE_PATH}/plugins/global`;
30 | }
31 |
32 | /**
33 | * Executed when a new messages comes in
34 | * @abstract
35 | */
36 | // eslint-disable-next-line no-unused-vars
37 | public async onNewMessageHook(message: string, _connection: Connection): Promise {
38 | return message;
39 | }
40 |
41 | /**
42 | * When a connection is created
43 | * @abstract
44 | */
45 | // eslint-disable-next-line no-unused-vars
46 | public async onNewConnection(_connection: Connection, _event: ConnectionAcceptedEvent): Promise {
47 | void 0;
48 | }
49 |
50 | /**
51 | * When a connection was closed
52 | * @abstract
53 | */
54 | // eslint-disable-next-line no-unused-vars
55 | public async onConnectionClosed(_connection: Connection): Promise {
56 | void 0;
57 | }
58 | }
59 |
60 | /**
61 | * Defines default constructor for a global plugin (required for TypeScript)
62 | */
63 | export interface GlobalPluginConstructor {
64 | new (manager: RoomManager): GlobalPlugin;
65 | commandName: string;
66 | commandAliases: string[];
67 | defaultDataStorageValue?: any;
68 | }
69 |
--------------------------------------------------------------------------------
/app/server/plugins/PluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { Room } from '../skychat/Room.js';
2 | import { RoomManager } from '../skychat/RoomManager.js';
3 | import { GlobalPlugin, GlobalPluginConstructor } from './GlobalPlugin.js';
4 | import { RoomPlugin, RoomPluginConstructor } from './RoomPlugin.js';
5 |
6 | /**
7 | * Defines a list of plugins that can be used together.
8 | * Used to make configuration easier.
9 | */
10 | export abstract class PluginGroup {
11 | /**
12 | * List of room plugin classes in this plugin group
13 | */
14 | abstract roomPluginClasses: Array;
15 |
16 | /**
17 | * List of global plugin classes in this plugin group
18 | */
19 | abstract globalPluginClasses: Array;
20 |
21 | /**
22 | * This method should return the list of instantiated plugins for the given room
23 | * @param room
24 | */
25 | instantiateRoomPlugins(room: Room): RoomPlugin[] {
26 | return this.roomPluginClasses.map((c) => new c(room));
27 | }
28 |
29 | /**
30 | * This method should return the list of instantiated plugins
31 | * @param manager
32 | */
33 | instantiateGlobalPlugins(manager: RoomManager): GlobalPlugin[] {
34 | return this.globalPluginClasses.map((c) => new c(manager));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/server/plugins/RoomPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../skychat/Connection.js';
2 | import { Message } from '../skychat/Message.js';
3 | import { Plugin } from './Plugin.js';
4 | import { Room } from '../skychat/Room.js';
5 |
6 | export abstract class RoomPlugin extends Plugin {
7 | /**
8 | * Some plugins (i.e. plugin to ban users) are globally available.
9 | * They have a single storage file, and they are instantiated only once at the root-level instead of once per room.
10 | * This static attribute need to be set
11 | */
12 | public static readonly isGlobal: boolean = false;
13 |
14 | /**
15 | * If the plugin is attached to a room, contain the reference to the room
16 | */
17 | public readonly room: Room;
18 |
19 | /**
20 | * A plugin is attached to a given room
21 | * @param room
22 | */
23 | constructor(room: Room) {
24 | super();
25 | this.room = room;
26 | }
27 |
28 | /**
29 | * Get this plugin's storage path
30 | * @override
31 | */
32 | public getStoragePath(): string {
33 | return `${Plugin.STORAGE_BASE_PATH}/plugins/${this.room.id}`;
34 | }
35 |
36 | /**
37 | * Get a summary of this plugin state to include in the room list
38 | */
39 | public getRoomSummary(): unknown {
40 | return null;
41 | }
42 |
43 | /**
44 | * Executed before broadcasting a message to the room
45 | * @abstract
46 | * @param message
47 | * @param _connection
48 | */
49 | // eslint-disable-next-line no-unused-vars
50 | public async onBeforeMessageBroadcastHook(message: Message, _connection?: Connection): Promise {
51 | return message;
52 | }
53 |
54 | /**
55 | * Executed before a connection joins a room
56 | * @abstract
57 | * @param _connection
58 | * @param _room
59 | */
60 | // eslint-disable-next-line no-unused-vars
61 | public async onBeforeConnectionJoinedRoom(_connection: Connection, _room: Room): Promise {
62 | void 0;
63 | }
64 |
65 | /**
66 | * Executed when a connection joins a room
67 | * @abstract
68 | * @param _connection
69 | */
70 | // eslint-disable-next-line no-unused-vars
71 | public async onConnectionJoinedRoom(_connection: Connection): Promise {
72 | void 0;
73 | }
74 |
75 | /**
76 | * Executed when a connection is closed
77 | * @abstract
78 | * @param _connection
79 | */
80 | // eslint-disable-next-line no-unused-vars
81 | public async onConnectionLeftRoom(_connection: Connection): Promise {
82 | void 0;
83 | }
84 | }
85 |
86 | /**
87 | * Defines default constructor for a room plugin (required for TypeScript)
88 | */
89 | export interface RoomPluginConstructor {
90 | // eslint-disable-next-line no-unused-vars
91 | new (_room: Room): RoomPlugin;
92 | commandName: string;
93 | commandAliases: string[];
94 | defaultDataStorageValue?: any;
95 | }
96 |
--------------------------------------------------------------------------------
/app/server/plugins/core/CorePluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { PluginGroup } from '../PluginGroup.js';
2 | import { AccountPlugin } from './global/AccountPlugin.js';
3 | import { AdminConfigPlugin } from './global/AdminConfigPlugin.js';
4 | import { AudioRecorderPlugin } from './global/AudioRecorderPlugin.js';
5 | import { AvatarPlugin } from './global/AvatarPlugin.js';
6 | import { BackupPlugin } from './global/BackupPlugin.js';
7 | import { BanPlugin } from './global/BanPlugin.js';
8 | import { BlacklistPlugin } from './global/BlacklistPlugin.js';
9 | import { ConnectedListPlugin } from './global/ConnectedListPlugin.js';
10 | import { CustomizationPlugin } from './global/CustomizationPlugin.js';
11 | import { IpPlugin } from './global/IpPlugin.js';
12 | import { JoinRoomPlugin } from './global/JoinRoomPlugin.js';
13 | import { KickPlugin } from './global/KickPlugin.js';
14 | import { MailPlugin } from './global/MailPlugin.js';
15 | import { MottoPlugin } from './global/MottoPlugin.js';
16 | import { MutePlugin } from './global/MutePlugin.js';
17 | import { OPPlugin } from './global/OPPlugin.js';
18 | import { PollPlugin } from './global/PollPlugin.js';
19 | import { PrivateMessagePlugin } from './global/PrivateMessagePlugin.js';
20 | import { ReactionPlugin } from './global/ReactionPlugin.js';
21 | import { SetRightPlugin } from './global/SetRightPlugin.js';
22 | import { StickerPlugin } from './global/StickerPlugin.js';
23 | import { VoidPlugin } from './global/VoidPlugin.js';
24 | import { WebPushPlugin } from './global/WebPushPlugin.js';
25 | import { WelcomePlugin } from './global/WelcomePlugin.js';
26 | import { XpTickerPlugin } from './global/XpTickerPlugin.js';
27 | import { HelpPlugin } from './room/HelpPlugin.js';
28 | import { MentionPlugin } from './room/MentionPlugin.js';
29 | import { MessageEditPlugin } from './room/MessageEditPlugin.js';
30 | import { MessageHistoryPlugin } from './room/MessageHistoryPlugin.js';
31 | import { MessagePlugin } from './room/MessagePlugin.js';
32 | import { MessageSeenPlugin } from './room/MessageSeenPlugin.js';
33 | import { RoomManagerPlugin } from './room/RoomManagerPlugin.js';
34 | import { TypingListPlugin } from './room/TypingListPlugin.js';
35 |
36 | export class CorePluginGroup extends PluginGroup {
37 | roomPluginClasses = [
38 | HelpPlugin,
39 | MentionPlugin,
40 | MessageEditPlugin,
41 | MessageHistoryPlugin,
42 | MessagePlugin,
43 | MessageSeenPlugin,
44 | RoomManagerPlugin,
45 | TypingListPlugin,
46 | ];
47 |
48 | globalPluginClasses = [
49 | AccountPlugin,
50 | AdminConfigPlugin,
51 | AudioRecorderPlugin,
52 | AvatarPlugin,
53 | BackupPlugin,
54 | BanPlugin,
55 | BlacklistPlugin,
56 | ConnectedListPlugin,
57 | CustomizationPlugin,
58 | IpPlugin,
59 | JoinRoomPlugin,
60 | KickPlugin,
61 | MailPlugin,
62 | MottoPlugin,
63 | MutePlugin,
64 | OPPlugin,
65 | PollPlugin,
66 | PrivateMessagePlugin,
67 | ReactionPlugin,
68 | SetRightPlugin,
69 | StickerPlugin,
70 | VoidPlugin,
71 | WebPushPlugin,
72 | WelcomePlugin,
73 | XpTickerPlugin,
74 | ];
75 | }
76 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/AdminConfigPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '../../../skychat/Config.js';
2 | import { GlobalPlugin } from '../../GlobalPlugin.js';
3 | import { Connection } from '../../../skychat/Connection.js';
4 | import { UserController } from '../../../skychat/UserController.js';
5 |
6 | export class AdminConfigPlugin extends GlobalPlugin {
7 | static readonly commandName = 'adminconfig';
8 |
9 | readonly opOnly = true;
10 |
11 | readonly rules = {
12 | adminconfig: {
13 | minCount: 1,
14 | params: [{ name: 'action', pattern: /^(reload)$/ }],
15 | },
16 | };
17 |
18 | async run(_alias: string, param: string, connection: Connection): Promise {
19 | // Update storage value
20 | const [action]: string[] = param.split(' ');
21 |
22 | switch (action) {
23 | case 'reload':
24 | this.handleReload(connection);
25 | break;
26 | }
27 | }
28 |
29 | async handleReload(connection: Connection) {
30 | Config.initialize();
31 |
32 | const content = 'Configuration reloaded';
33 | const message = UserController.createNeutralMessage({ content, id: 0 });
34 | connection.send('message', message.sanitized());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/BackupPlugin.ts:
--------------------------------------------------------------------------------
1 | import { exec } from 'child_process';
2 | import { Connection } from '../../../skychat/Connection.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { GlobalPlugin } from '../../GlobalPlugin.js';
5 |
6 | export class BackupPlugin extends GlobalPlugin {
7 | static readonly commandName = 'backup';
8 |
9 | readonly opOnly = true;
10 |
11 | readonly rules = {
12 | backup: {
13 | coolDown: 10 * 1000,
14 | },
15 | };
16 |
17 | async run(alias: string, param: string, connection: Connection): Promise {
18 | const filePath = await this.makeBackup();
19 | const content = `Backup created: ${filePath}`;
20 | const message = UserController.createNeutralMessage({ content, id: 0 });
21 | connection.send('message', message.sanitized());
22 | }
23 |
24 | public makeBackup(): Promise {
25 | return new Promise((resolve, reject) => {
26 | exec('sh app/script/backup.sh', (error, stdout, stderr) => {
27 | // If backup fails
28 | if (error || stderr) {
29 | return reject(error || new Error(stderr));
30 | }
31 | // If backup has been created
32 | resolve(stdout);
33 | });
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/ConnectedListPlugin.ts:
--------------------------------------------------------------------------------
1 | import * as jsondiffpatch from 'jsondiffpatch';
2 | import { Config } from '../../../skychat/Config.js';
3 | import { Connection } from '../../../skychat/Connection.js';
4 | import { RoomManager } from '../../../skychat/RoomManager.js';
5 | import { SanitizedSession, Session } from '../../../skychat/Session.js';
6 | import { GlobalPlugin } from '../../GlobalPlugin.js';
7 |
8 | /**
9 | * Handle the list of currently active connections
10 | */
11 | export class ConnectedListPlugin extends GlobalPlugin {
12 | static readonly SYNC_DELAY = 2 * 1000;
13 |
14 | static readonly commandName = 'connectedlist';
15 |
16 | readonly opOnly = true;
17 |
18 | private diffPatcher: jsondiffpatch.DiffPatcher;
19 |
20 | /**
21 | * Last connected list sent to clients
22 | */
23 | private lastConnectedList: unknown = null;
24 |
25 | constructor(manager: RoomManager) {
26 | super(manager);
27 |
28 | this.lastConnectedList = this.getConnectedList();
29 | this.diffPatcher = jsondiffpatch.create({
30 | objectHash: (obj: any, index?: number | undefined) => (obj as SanitizedSession)?.identifier ?? obj.id ?? index,
31 | });
32 | setInterval(this.sync.bind(this), ConnectedListPlugin.SYNC_DELAY);
33 | }
34 |
35 | public run(): Promise {
36 | throw new Error('Method not implemented.');
37 | }
38 |
39 | async onNewConnection(connection: Connection): Promise {
40 | connection.send('connected-list', this.lastConnectedList);
41 | }
42 |
43 | async onConnectionLeftRoom(): Promise {
44 | this.sync();
45 | }
46 |
47 | public sync(): void {
48 | this._patchClients();
49 | }
50 |
51 | private _sessionSortFunction(a: Session, b: Session) {
52 | const sortArray = (session: Session): Array => [
53 | session.connections.length > 0 ? 1 : 0,
54 | session.deadSince ? session.deadSince.getTime() : 0,
55 | session.user.right,
56 | session.user.xp,
57 | ];
58 | const aArray = sortArray(a);
59 | const bArray = sortArray(b);
60 | for (let i = 0; i < aArray.length; ++i) {
61 | if (aArray[i] !== bArray[i]) {
62 | return bArray[i] - aArray[i];
63 | }
64 | }
65 | return a.user.username.localeCompare(b.user.username);
66 | }
67 |
68 | private getConnectedList() {
69 | return Object.values(Session.sessions)
70 | .sort(this._sessionSortFunction)
71 | .map((sess) => sess.sanitized());
72 | }
73 |
74 | private _patchClients() {
75 | const realSessions = this.getConnectedList();
76 | const diff = this.diffPatcher.diff(this.lastConnectedList, realSessions);
77 | if (typeof diff === 'undefined') {
78 | return;
79 | }
80 |
81 | for (const session of Object.values(Session.sessions)) {
82 | for (const connection of session.connections) {
83 | if (connection.session.user.right < Config.PREFERENCES.minRightForConnectedList) {
84 | return;
85 | }
86 |
87 | connection.send('connected-list-patch', diff);
88 | }
89 | }
90 |
91 | this.lastConnectedList = realSessions;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/JoinRoomPlugin.ts:
--------------------------------------------------------------------------------
1 | import { ConnectionAcceptedEvent } from '../../../skychat/AuthBridge.js';
2 | import { Connection } from '../../../skychat/Connection.js';
3 | import { GlobalPlugin } from '../../GlobalPlugin.js';
4 |
5 | export class JoinRoomPlugin extends GlobalPlugin {
6 | static readonly CHANGE_USERNAME_PRICE = 2000;
7 |
8 | static readonly commandName = 'join';
9 |
10 | readonly minRight = -1;
11 |
12 | readonly rules = {
13 | join: {
14 | minCount: 1,
15 | maxCount: 1,
16 | maxCallsPer10Seconds: 10,
17 | params: [{ name: 'roomId', pattern: /^(\d+)$/ }],
18 | },
19 | };
20 |
21 | async run(_alias: string, param: string, connection: Connection): Promise {
22 | // Validate the room id
23 | const roomId = parseInt(param, 10);
24 | if (typeof roomId !== 'number') {
25 | throw new Error('Invalid room specified');
26 | }
27 |
28 | // Ensure room exists
29 | const room = this.manager.getRoomById(roomId);
30 | if (!room) {
31 | throw new Error('Invalid room specified');
32 | }
33 |
34 | // Ensure user is allowed to join the room
35 | if (room.isPrivate) {
36 | if (!room.whitelist.includes(connection.session.identifier)) {
37 | throw new Error('You are not allowed to join this room');
38 | }
39 | }
40 |
41 | // Join the room
42 | await room.attachConnection(connection);
43 | }
44 |
45 | async joinRoom(connection: Connection, roomId: number) {
46 | // Ensure room exists
47 | const room = this.manager.getRoomById(roomId);
48 | if (!room) {
49 | throw new Error('Invalid room specified');
50 | }
51 |
52 | // Ensure user is allowed to join the room
53 | if (room.isPrivate) {
54 | if (!room.whitelist.includes(connection.session.identifier)) {
55 | throw new Error('You are not allowed to join this room');
56 | }
57 | }
58 |
59 | // Join the room
60 | await room.attachConnection(connection);
61 | }
62 |
63 | async onNewConnection(connection: Connection, event: ConnectionAcceptedEvent) {
64 | // Try to join the room specified in the event data
65 | let savedRoomId: number | undefined = undefined;
66 | if (typeof event.data.roomId === 'number') {
67 | const room = this.manager.getRoomById(event.data.roomId);
68 | if (room && room.accepts(connection.session)) {
69 | savedRoomId = event.data.roomId;
70 | }
71 | }
72 |
73 | // Try to join any room
74 | const anyRoom = this.manager.findSuitableRoom(connection);
75 | if (!anyRoom) {
76 | throw new Error('No room found. Is the server correctly configured?');
77 | }
78 |
79 | await this.joinRoom(connection, savedRoomId ?? anyRoom.id);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/KickPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { User } from '../../../skychat/User.js';
3 | import { Session } from '../../../skychat/Session.js';
4 | import { GlobalPlugin } from '../../GlobalPlugin.js';
5 | import { Config } from '../../../skychat/Config.js';
6 |
7 | /**
8 | * The kick plugin allows to force the disconnection of all the connections belonging to a session
9 | */
10 | export class KickPlugin extends GlobalPlugin {
11 | static readonly commandName = 'kick';
12 |
13 | readonly minRight = Config.PREFERENCES.minRightForUserModeration === 'op' ? 0 : Config.PREFERENCES.minRightForUserModeration;
14 |
15 | readonly opOnly = Config.PREFERENCES.minRightForUserModeration === 'op';
16 |
17 | readonly rules = {
18 | kick: {
19 | minCount: 1,
20 | maxCount: 1,
21 | params: [{ name: 'username', pattern: User.USERNAME_REGEXP }],
22 | },
23 | };
24 |
25 | async run(alias: string, param: string): Promise {
26 | const identifier = param.toLowerCase();
27 | const session = Session.getSessionByIdentifier(identifier);
28 | if (!session) {
29 | throw new Error('Username not found');
30 | }
31 | for (const connection of session.connections) {
32 | connection.close(Connection.CLOSE_KICKED, 'You have been kicked');
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/MottoPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { GlobalPlugin } from '../../GlobalPlugin.js';
3 | import { ConnectedListPlugin } from '../../core/global/ConnectedListPlugin.js';
4 | import { UserController } from '../../../skychat/UserController.js';
5 |
6 | export class MottoPlugin extends GlobalPlugin {
7 | private static MOTTO_MAX_LENGTH = 64;
8 |
9 | static readonly commandName = 'motto';
10 |
11 | static readonly defaultDataStorageValue = '';
12 |
13 | readonly minRight = 0;
14 |
15 | readonly rules = {
16 | motto: {
17 | minCount: 0,
18 | params: [
19 | {
20 | name: 'motto',
21 | pattern: /./,
22 | info: 'Be inspired',
23 | },
24 | ],
25 | },
26 | };
27 |
28 | async run(alias: string, param: string, connection: Connection): Promise {
29 | if (param.length > MottoPlugin.MOTTO_MAX_LENGTH) {
30 | throw new Error('Motto too long');
31 | }
32 | await UserController.savePluginData(connection.session.user, this.commandName, param);
33 | (this.manager.getPlugin('connectedlist') as ConnectedListPlugin).sync();
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/MutePlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { GlobalPlugin } from '../../GlobalPlugin.js';
3 |
4 | export class MutePlugin extends GlobalPlugin {
5 | static readonly commandName = 'mute';
6 |
7 | static readonly COMMAND_UNMUTE_NAME = 'unmute';
8 | static readonly COMMAND_MUTELIST_NAME = 'mutelist';
9 |
10 | static readonly commandAliases = [MutePlugin.commandName, MutePlugin.COMMAND_UNMUTE_NAME, MutePlugin.COMMAND_MUTELIST_NAME];
11 |
12 | readonly rules = {
13 | [MutePlugin.commandName]: {
14 | minCount: 1,
15 | maxCount: 1,
16 | params: [{ name: 'roomId', pattern: /^\d+$/ }],
17 | },
18 | [MutePlugin.COMMAND_UNMUTE_NAME]: {
19 | minCount: 1,
20 | maxCount: 1,
21 | params: [{ name: 'roomId', pattern: /^\d+$/ }],
22 | },
23 | [MutePlugin.COMMAND_MUTELIST_NAME]: {
24 | minCount: 0,
25 | maxCount: 0,
26 | },
27 | };
28 |
29 | readonly minRight = 0;
30 |
31 | async run(alias: string, param: string, connection: Connection): Promise {
32 | const user = connection.session.user;
33 | const list: number[] = this.getUserData(user) ?? [];
34 | const roomId = +param;
35 | const index = list.indexOf(roomId);
36 |
37 | switch (alias) {
38 | case 'mute':
39 | if (this.manager.getRoomById(roomId) === undefined) {
40 | connection.send('error', `❌ Room ${roomId} does not exist`);
41 | return;
42 | }
43 | if (!list.includes(roomId)) {
44 | list.push(roomId);
45 | this.saveUserData(user, list);
46 | connection.session.syncUserData();
47 | }
48 | connection.send('info', `🔇 Muted room ${roomId}`);
49 | break;
50 |
51 | case 'unmute':
52 | if (index !== -1) {
53 | list.splice(index, 1);
54 | this.saveUserData(user, list);
55 | connection.session.syncUserData();
56 | }
57 | connection.send('info', `🔊 Unmuted room ${roomId}`);
58 | break;
59 |
60 | case 'mutelist':
61 | connection.send('info', list.length ? `🔕 Currently muted rooms: ${list.join(', ')}` : '🔕 No muted rooms');
62 | break;
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/OPPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { UserController } from '../../../skychat/UserController.js';
3 | import { GlobalPlugin } from '../../GlobalPlugin.js';
4 |
5 | export class OPPlugin extends GlobalPlugin {
6 | static readonly commandName = 'op';
7 |
8 | static readonly commandAliases = ['opexit'];
9 |
10 | readonly minRight = 0;
11 |
12 | readonly rules = {
13 | op: {
14 | minCount: 0,
15 | maxCount: 1,
16 | coolDown: 1000,
17 | maxCallsPer10Seconds: 2,
18 | },
19 | opexit: {
20 | maxCount: 0,
21 | },
22 | };
23 |
24 | async run(alias: string, param: string, connection: Connection): Promise {
25 | // Check that the user identifier is in the list of OP usernames
26 | if (!process.env.OP_LIST || process.env.OP_LIST.trim().length === 0) {
27 | throw new Error(
28 | 'No OP list was set. Please set the OP_LIST environment variable if you want to have admin access to your instance.',
29 | );
30 | }
31 | const opList = process.env.OP_LIST.toLowerCase().split(',');
32 | if (!opList.includes(connection.session.identifier.toLowerCase())) {
33 | throw new Error('Not OP');
34 | }
35 |
36 | if (alias === 'op') {
37 | return this.handleOP(param, connection);
38 | }
39 |
40 | if (alias === 'opexit') {
41 | return this.handleOpExit(param, connection);
42 | }
43 | }
44 |
45 | async handleOP(param: string, connection: Connection): Promise {
46 | if (!process.env.OP_PASSCODE || process.env.OP_PASSCODE.trim().length === 0) {
47 | throw new Error('OP passcode not set');
48 | } else if (param !== process.env.OP_PASSCODE) {
49 | throw new Error('Invalid passcode');
50 | }
51 | connection.session.setOP(true);
52 | connection.send(
53 | 'message',
54 | UserController.createNeutralMessage({
55 | id: 0,
56 | content: 'OP mode enabled 😲',
57 | }).sanitized(),
58 | );
59 | }
60 |
61 | async handleOpExit(param: string, connection: Connection): Promise {
62 | if (!connection.session.isOP()) {
63 | throw new Error('Not op');
64 | }
65 | connection.session.setOP(false);
66 | connection.send(
67 | 'message',
68 | UserController.createNeutralMessage({
69 | id: 0,
70 | content: 'OP mode disabled 🤔',
71 | }).sanitized(),
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/SetRightPlugin.ts:
--------------------------------------------------------------------------------
1 | import { User } from '../../../skychat/User.js';
2 | import { Session } from '../../../skychat/Session.js';
3 | import { ConnectedListPlugin } from './ConnectedListPlugin.js';
4 | import { UserController } from '../../../skychat/UserController.js';
5 | import { GlobalPlugin } from '../../GlobalPlugin.js';
6 | import { Config } from '../../../skychat/Config.js';
7 | import lodash from 'lodash';
8 | import { Connection } from '../../../skychat/Connection.js';
9 |
10 | export class SetRightPlugin extends GlobalPlugin {
11 | static readonly commandName = 'setright';
12 |
13 | readonly rules = {
14 | setright: {
15 | minCount: 2,
16 | maxCount: 2,
17 | params: [
18 | { name: 'username', pattern: User.USERNAME_LOGGED_REGEXP },
19 | { name: 'right', pattern: /^([0-9]+)$/ },
20 | ],
21 | },
22 | };
23 |
24 | readonly minRight = Config.PREFERENCES.minRightForSetRight === 'op' ? 0 : Config.PREFERENCES.minRightForSetRight;
25 |
26 | readonly opOnly = Config.PREFERENCES.minRightForSetRight === 'op';
27 |
28 | async run(_alias: string, param: string, connection: Connection): Promise {
29 | const [usernameRaw, rightRaw] = param.split(' ');
30 | const identifier = usernameRaw.toLowerCase();
31 | const right = parseInt(rightRaw);
32 |
33 | if (!lodash.isInteger(right)) {
34 | throw new Error('Invalid right');
35 | }
36 |
37 | const session = Session.getSessionByIdentifier(identifier);
38 | if (!session) {
39 | throw new Error('User not found');
40 | }
41 |
42 | if (connection.session.user.right <= session.user.right && !connection.session.isOP()) {
43 | throw new Error('You cannot change the right of a user with a higher or equal right than yours');
44 | }
45 |
46 | if (right >= connection.session.user.right && !connection.session.isOP()) {
47 | throw new Error('You cannot set a right higher or equal to yours');
48 | }
49 |
50 | const user = session.user;
51 | user.right = right;
52 | await UserController.sync(user);
53 | (this.manager.getPlugin('connectedlist') as ConnectedListPlugin).sync();
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/VoidPlugin.ts:
--------------------------------------------------------------------------------
1 | import { GlobalPlugin } from '../../GlobalPlugin.js';
2 |
3 | export class VoidPlugin extends GlobalPlugin {
4 | static readonly commandName = 'void';
5 | readonly minRight = -1;
6 | readonly hidden = true;
7 | async run(): Promise {
8 | void 0;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/WelcomePlugin.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '../../../skychat/Config.js';
2 | import { Connection } from '../../../skychat/Connection.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { GlobalPlugin } from '../../GlobalPlugin.js';
5 |
6 | export class WelcomePlugin extends GlobalPlugin {
7 | static readonly commandName = 'welcome';
8 |
9 | readonly callable = false;
10 |
11 | async run() {
12 | throw new Error('Method not implemented.');
13 | }
14 |
15 | async onNewConnection(connection: Connection) {
16 | const message = Config.WELCOME_MESSAGE;
17 | if (!message) {
18 | return;
19 | }
20 |
21 | if (connection.session.user.id > 0) {
22 | return;
23 | }
24 |
25 | connection.send('message', UserController.createNeutralMessage({ content: message, room: connection.roomId, id: 0 }).sanitized());
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/server/plugins/core/global/XpTickerPlugin.ts:
--------------------------------------------------------------------------------
1 | import { GlobalPlugin } from '../../GlobalPlugin.js';
2 | import { ConnectedListPlugin } from './ConnectedListPlugin.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { Session } from '../../../skychat/Session.js';
5 | import { RoomManager } from '../../../skychat/RoomManager.js';
6 |
7 | export class XpTickerPlugin extends GlobalPlugin {
8 | public static readonly MAX_INACTIVITY_DURATION_MS: number = 5 * 60 * 1000;
9 |
10 | static readonly commandName = 'xp';
11 |
12 | callable = false;
13 |
14 | constructor(manager: RoomManager) {
15 | super(manager);
16 |
17 | setInterval(this.tick.bind(this), 60 * 1000);
18 | }
19 |
20 | async run(): Promise {
21 | throw new Error('Not implemented');
22 | }
23 |
24 | private async tick(): Promise {
25 | // Get rooms in the session
26 | const sessions = Object.values(Session.sessions);
27 | // For each session in the room
28 | for (const session of sessions) {
29 | // If it's not a logged session, continue
30 | if (session.user.right < 0) {
31 | continue;
32 | }
33 | // If user inactive for too long, continue
34 | if (session.lastPublicMessageSentDate.getTime() + XpTickerPlugin.MAX_INACTIVITY_DURATION_MS < new Date().getTime()) {
35 | continue;
36 | }
37 | await UserController.giveXP(session.user, 1);
38 | }
39 | (this.manager.getPlugin('connectedlist') as ConnectedListPlugin).sync();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/server/plugins/core/index.ts:
--------------------------------------------------------------------------------
1 | export { CorePluginGroup } from './CorePluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/plugins/core/room/HelpPlugin.ts:
--------------------------------------------------------------------------------
1 | import striptags from 'striptags';
2 | import { Connection } from '../../../skychat/Connection.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { Plugin, PluginCommandRules } from '../../Plugin.js';
5 | import { RoomPlugin } from '../../RoomPlugin.js';
6 |
7 | export class HelpPlugin extends RoomPlugin {
8 | static readonly commandName = 'help';
9 |
10 | readonly minRight = -1;
11 |
12 | async run(alias: string, param: string, connection: Connection): Promise {
13 | // Group commands by main name
14 | const commandAliases: [string, Plugin][] = (Object.entries(this.room.commands) as [string, Plugin][]).concat(
15 | Object.entries(this.room.manager.pluginManager.commands),
16 | );
17 | let content = '';
18 | content += `
19 |
20 | name |
21 | min right |
22 | cooldown |
23 | params |
24 |
25 | `;
26 | for (const [alias, command] of commandAliases) {
27 | // If user has not the right to access the command, hide it
28 | if (connection.session.user.right < command.minRight) {
29 | continue;
30 | }
31 | if (command.opOnly && !connection.session.isOP()) {
32 | continue;
33 | }
34 |
35 | // If command is not callable
36 | if (!command.callable || command.hidden) {
37 | continue;
38 | }
39 |
40 | // Get rule object
41 | const rules: PluginCommandRules = command.rules && command.rules[alias] ? command.rules[alias] : {};
42 | const coolDown = Array.isArray(rules.coolDown) ? rules.coolDown.toString() : (rules.coolDown ?? 0) / 1000;
43 |
44 | content += `
45 |
46 | ${alias} |
47 | ${command.minRight} |
48 | ${coolDown}s |
49 | ${(rules.params ?? []).map((param) => param.name).join(', ')} |
50 |
51 | `;
52 | }
53 | content += '
';
54 | const message = UserController.createNeutralMessage({ content, room: this.room.id, id: 0 });
55 | message.edit(striptags(content), content);
56 | connection.send('message', message.sanitized());
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/server/plugins/core/room/MessageEditPlugin.ts:
--------------------------------------------------------------------------------
1 | import SQL from 'sql-template-strings';
2 | import { Connection } from '../../../skychat/Connection.js';
3 | import { DatabaseHelper } from '../../../skychat/DatabaseHelper.js';
4 | import { RoomPlugin } from '../../RoomPlugin.js';
5 | import { MessageLimiterPlugin } from '../../security_extra/MessageLimiterPlugin.js';
6 |
7 | export class MessageEditPlugin extends RoomPlugin {
8 | static readonly commandName = 'edit';
9 |
10 | static readonly commandAliases = ['delete'];
11 |
12 | readonly minRight = 0;
13 |
14 | readonly rules = {
15 | edit: {
16 | minCount: 2,
17 | coolDown: 2000,
18 | params: [
19 | { pattern: /^([0-9]+)$/, name: 'id' },
20 | { pattern: /.?/, name: 'message' },
21 | ],
22 | },
23 | delete: {
24 | minCount: 1,
25 | maxCount: 1,
26 | coolDown: 2000,
27 | params: [{ pattern: /^([0-9]+)$/, name: 'id' }],
28 | },
29 | };
30 |
31 | async run(alias: string, param: string, connection: Connection): Promise {
32 | const id = parseInt(param.split(' ')[0]);
33 |
34 | // Find message
35 | const message = await this.room.getMessageById(id);
36 |
37 | if (!message) {
38 | throw new Error('Message not found');
39 | }
40 |
41 | // Check rights
42 | if (message.user.id !== connection.session.user.id && !connection.session.isOP()) {
43 | throw new Error('You can only edit your own messages');
44 | }
45 |
46 | // Edit message
47 | if (alias === 'edit') {
48 | const content = param.split(' ').slice(1).join(' ');
49 | if (!this.room.getPlugin(MessageLimiterPlugin.commandName)?.allowMessageEdit(message, content)) {
50 | throw new Error(MessageLimiterPlugin.errorMessage);
51 | }
52 | message.edit(content);
53 | } else {
54 | message.edit('deleted', 'deleted');
55 | }
56 |
57 | // Store it into the database
58 | await DatabaseHelper.db.query(SQL`update messages set content = ${message.content} where id = ${message.id}`);
59 |
60 | // Notify room
61 | this.room.send('message-edit', message.sanitized());
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/server/plugins/core/room/MessageSeenPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { UserController } from '../../../skychat/UserController.js';
3 | import { RoomPlugin } from '../../RoomPlugin.js';
4 |
5 | export type MessageSeenEventData = {
6 | user: number;
7 | data: { [room: number]: number };
8 | };
9 |
10 | export class MessageSeenPlugin extends RoomPlugin {
11 | static readonly commandName = 'lastseen';
12 |
13 | static readonly defaultDataStorageValue = {};
14 |
15 | /**
16 | * We need to allow guests to send /lastseen even though it is not recorded in the backend because sometimes,
17 | * the client does not know it own right level, therefore it would always send /lastseen
18 | */
19 | readonly minRight = -1;
20 |
21 | readonly hidden = true;
22 |
23 | readonly rules = {
24 | lastseen: {
25 | minCount: 1,
26 | maxCount: 1,
27 | maxCallsPer10Seconds: 40,
28 | params: [
29 | {
30 | name: 'message id',
31 | pattern: /^[0-9]+$/,
32 | info: 'Id of the last seen message',
33 | },
34 | ],
35 | },
36 | };
37 |
38 | async run(_alias: string, param: string, connection: Connection): Promise {
39 | if (connection.session.user.isGuest()) {
40 | return;
41 | }
42 | // Parse new last message seen id
43 | const newLastMessageSeen = parseInt(param);
44 | const message = this.room.getMessageById(newLastMessageSeen);
45 | if (!message) {
46 | return;
47 | }
48 | // Load previous data from the plugin storage. An object mapping room ids to last message seen.
49 | let pluginData = UserController.getUserPluginData<{ [roomId: number]: number }>(connection.session.user, this.commandName);
50 | if (typeof pluginData !== 'object') {
51 | pluginData = {};
52 | }
53 | // Clean plugin data to only reflect rooms that still exists
54 | for (const roomId in pluginData) {
55 | if (!this.room.manager.getRoomById(parseInt(roomId))) {
56 | delete pluginData[roomId];
57 | }
58 | }
59 | // Check that the new last message seen id is greater than the previous one
60 | if (newLastMessageSeen <= pluginData[this.room.id]) {
61 | return;
62 | }
63 | // Update plugin data
64 | pluginData[this.room.id] = message.id;
65 | // Save plugin data with the new last message seen id
66 | await UserController.savePluginData(connection.session.user, this.commandName, pluginData);
67 | // Send update to other in this room
68 | this.room.send('message-seen', {
69 | user: connection.session.user.id,
70 | data: pluginData,
71 | } as MessageSeenEventData);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/app/server/plugins/core/room/TypingListPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { RoomPlugin } from '../../RoomPlugin.js';
3 | import { User } from '../../../skychat/User.js';
4 |
5 | /**
6 | * Handle cursor events
7 | */
8 | export class TypingListPlugin extends RoomPlugin {
9 | static readonly commandName = 't';
10 |
11 | readonly minRight = -1;
12 |
13 | readonly rules = {
14 | t: {
15 | minCount: 1,
16 | maxCount: 1,
17 | params: [{ name: 'action', pattern: /^(on|off|clear)$/ }],
18 | },
19 | };
20 |
21 | readonly hidden = true;
22 |
23 | /**
24 | * Identifiers that are currently typing and the associated date when they started typing
25 | */
26 | private typingList: { [identifier: string]: { startedDate: Date; user: User } } = {};
27 |
28 | async run(alias: string, param: string, connection: Connection): Promise {
29 | if (param === 'clear') {
30 | // Check rights
31 | if (!connection.session.isOP()) {
32 | throw new Error('You do not have the right to clear the typing list');
33 | }
34 | this.typingList = {};
35 | } else if (param === 'on') {
36 | // Register typer
37 | this.typingList[connection.session.identifier] = {
38 | startedDate: new Date(),
39 | user: connection.session.user,
40 | };
41 | } else {
42 | // Remove typer
43 | delete this.typingList[connection.session.identifier];
44 | }
45 |
46 | this.sync();
47 | }
48 |
49 | async onConnectionJoinedRoom(): Promise {
50 | this.sync();
51 | }
52 |
53 | async onConnectionLeftRoom(connection: Connection): Promise {
54 | delete this.typingList[connection.session.identifier];
55 | }
56 |
57 | private sync(): void {
58 | this.room.send(
59 | 'typing-list',
60 | Object.values(this.typingList).map((entry) => entry.user.sanitized()),
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/server/plugins/gallery/GalleryPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '../../skychat/Config.js';
2 | import { Connection } from '../../skychat/Connection.js';
3 | import { GlobalPlugin } from '../GlobalPlugin.js';
4 | import { Gallery } from './Gallery.js';
5 |
6 | /**
7 | *
8 | */
9 | export class GalleryPlugin extends GlobalPlugin {
10 | static readonly commandName = 'gallery';
11 |
12 | static readonly commandAliases = ['galleryls', 'galleryrm'];
13 |
14 | readonly minRight = typeof Config.PREFERENCES.minRightForGalleryRead === 'number' ? Config.PREFERENCES.minRightForGalleryRead : 0;
15 |
16 | readonly opOnly = Config.PREFERENCES.minRightForGalleryRead === 'op';
17 |
18 | readonly rules = {
19 | gallery: {},
20 | galleryls: {
21 | minCount: 0,
22 | maxCount: 1,
23 | params: [{ name: 'path', pattern: Gallery.FOLDER_PATH_REGEX }],
24 | },
25 | galleryrm: {
26 | minCount: 1,
27 | maxCount: 1,
28 | params: [{ name: 'path', pattern: Gallery.FILE_PATH_REGEX }],
29 | },
30 | };
31 |
32 | async run(alias: string, param: string, connection: Connection): Promise {
33 | switch (alias) {
34 | case 'galleryls':
35 | Gallery.ensureNoParentDirectoryAccess(param);
36 | connection.send('gallery', await Gallery.ls(param));
37 | break;
38 |
39 | case 'galleryrm':
40 | if (!Gallery.canDelete(connection.session)) {
41 | throw new Error('You do not have the permission to delete files');
42 | }
43 | Gallery.ensureNoParentDirectoryAccess(param);
44 | connection.send('gallery', await Gallery.rm(param));
45 | break;
46 |
47 | default:
48 | throw new Error(`Unknown alias ${alias}`);
49 | }
50 | }
51 |
52 | async onNewConnection(connection: Connection): Promise {
53 | // If gallery was already sent
54 | if (Config.PREFERENCES.minRightForGalleryRead === -1 || Gallery.canRead(connection.session)) {
55 | connection.send('gallery', await Gallery.ls(''));
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/server/plugins/gallery/GalleryPluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { PluginGroup } from '../PluginGroup.js';
2 | import { GalleryPlugin } from './GalleryPlugin.js';
3 | import { VideoConverterPlugin } from './VideoConverterPlugin.js';
4 |
5 | export class GalleryPluginGroup extends PluginGroup {
6 | roomPluginClasses = [];
7 |
8 | globalPluginClasses = [GalleryPlugin, VideoConverterPlugin];
9 | }
10 |
--------------------------------------------------------------------------------
/app/server/plugins/gallery/index.ts:
--------------------------------------------------------------------------------
1 | export { GalleryPluginGroup } from './GalleryPluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/plugins/games/GamesPluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { PluginGroup } from '../PluginGroup.js';
2 | import { AprilFoolsDay } from './global/AprilFoolsDay.js';
3 | import { ConfusePlugin } from './global/ConfusePlugin.js';
4 | import { CursorPlugin } from './global/CursorPlugin.js';
5 | import { MoneyFarmerPlugin } from './global/MoneyFarmerPlugin.js';
6 | import { OfferMoneyPlugin } from './global/OfferMoneyPlugin.js';
7 | import { SandalePlugin } from './global/SandalePlugin.js';
8 | import { DailyRollPlugin } from './room/DailyRollPlugin.js';
9 | import { GiveMoneyPlugin } from './room/GiveMoneyPlugin.js';
10 | import { GuessTheNumberPlugin } from './room/GuessTheNumberPlugin.js';
11 | import { PointsCollectorPlugin } from './room/PointsCollectorPlugin.js';
12 | import { RacingPlugin } from './room/RacingPlugin.js';
13 | import { RandomGeneratorPlugin } from './room/RandomGeneratorPlugin.js';
14 | import { RollPlugin } from './room/RollPlugin.js';
15 | import { StatsPlugin } from './room/StatsPlugin.js';
16 | import { UserPollPlugin } from './room/UserPollPlugin.js';
17 |
18 | export class GamesPluginGroup extends PluginGroup {
19 | roomPluginClasses = [
20 | DailyRollPlugin,
21 | GuessTheNumberPlugin,
22 | GiveMoneyPlugin,
23 | PointsCollectorPlugin,
24 | RacingPlugin,
25 | RandomGeneratorPlugin,
26 | RollPlugin,
27 | StatsPlugin,
28 | ];
29 |
30 | globalPluginClasses = [AprilFoolsDay, UserPollPlugin, ConfusePlugin, CursorPlugin, MoneyFarmerPlugin, OfferMoneyPlugin, SandalePlugin];
31 | }
32 |
--------------------------------------------------------------------------------
/app/server/plugins/games/global/AprilFoolsDay.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { GlobalPlugin } from '../../GlobalPlugin.js';
3 |
4 | export class AprilFoolsDay extends GlobalPlugin {
5 | static readonly commandName = 'aprilfoolsday';
6 |
7 | readonly minRight = -1;
8 |
9 | readonly hidden = true;
10 |
11 | readonly rules = {
12 | aprilfoolsday: {},
13 | };
14 |
15 | async run(alias: string, param: string, connection: Connection): Promise {
16 | // Make the UI think the user became OP
17 | connection.send('set-op', true);
18 | throw new Error('Internal Server Error: Your account has been given maximum privilege');
19 | }
20 |
21 | public async onNewMessageHook(message: string): Promise {
22 | const localDate = new Date();
23 | if (localDate.getMonth() !== 3 || localDate.getDate() !== 1) {
24 | return message;
25 | }
26 |
27 | if (!message.startsWith('/message ')) {
28 | return message;
29 | }
30 |
31 | if (Math.random() > 1 / 10) {
32 | return message;
33 | }
34 |
35 | const words = message
36 | .split(' ')
37 | .slice(1)
38 | .sort(() => Math.random() - 0.5);
39 | return '/message ' + words.join(' ');
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/server/plugins/games/global/MoneyFarmerPlugin.ts:
--------------------------------------------------------------------------------
1 | import { User } from '../../../skychat/User.js';
2 | import { ConnectedListPlugin } from '../../core/global/ConnectedListPlugin.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { GlobalPlugin } from '../../GlobalPlugin.js';
5 | import { Session } from '../../../skychat/Session.js';
6 | import { RoomManager } from '../../../skychat/RoomManager.js';
7 |
8 | export class MoneyFarmerPlugin extends GlobalPlugin {
9 | public static readonly MAX_INACTIVITY_DURATION_MS: number = 5 * 60 * 1000;
10 |
11 | public static readonly TICK_AMOUNTS_LIMITS: { limit: number; amount: number }[] = [
12 | { limit: 15 * 100, amount: 3 },
13 | { limit: 30 * 100, amount: 2 },
14 | { limit: 100 * 100, amount: 1 },
15 | ];
16 |
17 | static readonly commandName = 'moneyfarmer';
18 |
19 | readonly minRight = -1;
20 |
21 | readonly callable = false;
22 |
23 | constructor(manager: RoomManager) {
24 | super(manager);
25 |
26 | setInterval(this.tick.bind(this), 60 * 1000);
27 | }
28 |
29 | async run(): Promise {
30 | void 0;
31 | }
32 |
33 | /**
34 | * Get the amount to give to a specific user for this tick
35 | * @param user
36 | */
37 | private getTickAmount(user: User): number {
38 | const entry = MoneyFarmerPlugin.TICK_AMOUNTS_LIMITS.filter((entry) => entry.limit >= user.money)[0];
39 | return entry ? entry.amount : 0;
40 | }
41 |
42 | private async tick(): Promise {
43 | // Get all sessions
44 | const sessions = Object.values(Session.sessions);
45 | // For each session in the room
46 | for (const session of sessions) {
47 | // If it's not a logged session, continue
48 | if (session.user.right < 0) {
49 | continue;
50 | }
51 | // If user inactive for too long, continue
52 | if (session.lastPublicMessageSentDate.getTime() + MoneyFarmerPlugin.MAX_INACTIVITY_DURATION_MS < new Date().getTime()) {
53 | continue;
54 | }
55 |
56 | const amount = this.getTickAmount(session.user);
57 | if (amount === 0) {
58 | continue;
59 | }
60 | session.user.money += amount;
61 | await UserController.sync(session.user);
62 | }
63 | (this.manager.getPlugin('connectedlist') as ConnectedListPlugin).sync();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/server/plugins/games/global/OfferMoneyPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { GlobalPlugin } from '../../GlobalPlugin.js';
3 | import { Session } from '../../../skychat/Session.js';
4 | import { User } from '../../../skychat/User.js';
5 | import { ConnectedListPlugin } from '../../core/global/ConnectedListPlugin.js';
6 | import { UserController } from '../../../skychat/UserController.js';
7 |
8 | export class OfferMoneyPlugin extends GlobalPlugin {
9 | static readonly commandName = 'offermoney';
10 |
11 | readonly minRight = 0;
12 |
13 | readonly opOnly = true;
14 |
15 | readonly rules = {
16 | offermoney: {
17 | minCount: 2,
18 | maxCount: 2,
19 | coolDown: 50,
20 | params: [
21 | { name: 'username', pattern: User.USERNAME_LOGGED_REGEXP },
22 | { name: 'amount', pattern: /^([0-9]+)$/ },
23 | ],
24 | },
25 | };
26 |
27 | async run(alias: string, param: string, connection: Connection): Promise {
28 | const identifier = param.split(' ')[0].toLowerCase();
29 | const session = Session.getSessionByIdentifier(identifier);
30 | if (!session) {
31 | throw new Error('User not found');
32 | }
33 |
34 | const amount = parseInt(param.split(' ')[1]);
35 | await UserController.giveMoney(session.user, amount);
36 | session.send(
37 | 'message',
38 | UserController.createNeutralMessage({
39 | content: connection.session.user.username + ' sent you $ ' + amount / 100,
40 | room: connection.roomId,
41 | id: 0,
42 | }).sanitized(),
43 | );
44 | (this.manager.getPlugin('connectedlist') as ConnectedListPlugin).sync();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/server/plugins/games/index.ts:
--------------------------------------------------------------------------------
1 | export { GamesPluginGroup } from './GamesPluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/plugins/games/room/GiveMoneyPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { RoomPlugin } from '../../RoomPlugin.js';
3 | import { Session } from '../../../skychat/Session.js';
4 | import { User } from '../../../skychat/User.js';
5 | import { ConnectedListPlugin } from '../../core/global/ConnectedListPlugin.js';
6 | import { UserController } from '../../../skychat/UserController.js';
7 |
8 | export class GiveMoneyPlugin extends RoomPlugin {
9 | public static readonly COMMISSION_PERCENTAGE: number = 0.2;
10 |
11 | public static readonly COMMISSION_MIN: number = 1;
12 |
13 | static readonly commandName = 'give';
14 |
15 | readonly minRight = 0;
16 |
17 | readonly rules = {
18 | give: {
19 | minCount: 2,
20 | maxCount: 2,
21 | coolDown: 100,
22 | params: [
23 | { name: 'username', pattern: User.USERNAME_REGEXP },
24 | { name: 'amount', pattern: /^([0-9]+)$/ },
25 | ],
26 | },
27 | };
28 |
29 | async run(alias: string, param: string, connection: Connection): Promise {
30 | // Get information about receiver, sender and amount
31 | const receiverUsername = param.split(' ')[0];
32 | const receiverSession = Session.getSessionByIdentifier(receiverUsername);
33 | if (!receiverSession) {
34 | throw new Error('User not found');
35 | }
36 | const totalAmount = parseInt(param.split(' ')[1]);
37 | const senderSession = connection.session;
38 |
39 | // Compute commission amount
40 | const commission = Math.floor(Math.max(GiveMoneyPlugin.COMMISSION_MIN, GiveMoneyPlugin.COMMISSION_PERCENTAGE * totalAmount));
41 | const givenAmount = totalAmount - commission;
42 |
43 | // If amount is zero substracting the commission
44 | if (givenAmount <= 0) {
45 | throw new Error('Given amount is zero');
46 | }
47 |
48 | // Actually transfer the money
49 | await UserController.buy(senderSession.user, totalAmount);
50 | await UserController.giveMoney(receiverSession.user, givenAmount);
51 |
52 | // Notify the receiver & sender
53 | this.room.send('give', {
54 | sender: senderSession.user.sanitized(),
55 | receiver: receiverSession.user.sanitized(),
56 | givenAmount,
57 | commission,
58 | });
59 | let message = senderSession.user.username + ' sent $' + givenAmount / 100 + ' to ' + receiverSession.user.username;
60 | if (commission > 0) {
61 | message += ' (- $' + commission / 100 + ' commission)';
62 | }
63 | await this.room.sendMessage({
64 | content: message,
65 | user: UserController.getNeutralUser(),
66 | });
67 | (this.room.getPlugin('connectedlist') as unknown as ConnectedListPlugin).sync();
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/server/plugins/games/room/RandomGeneratorPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../../skychat/Connection.js';
2 | import { RoomPlugin } from '../../RoomPlugin.js';
3 | import { UserController } from '../../../skychat/UserController.js';
4 | import { RandomGenerator } from '../../../skychat/RandomGenerator.js';
5 |
6 | export class RandomGeneratorPlugin extends RoomPlugin {
7 | static readonly commandName = 'rand';
8 |
9 | readonly minRight = 0;
10 |
11 | readonly rules = {
12 | rand: {
13 | minCount: 2,
14 | maxCount: 2,
15 | coolDown: 100,
16 | params: [
17 | { name: 'min', pattern: /^([0-9]+)$/ },
18 | { name: 'max', pattern: /^([0-9]+)$/ },
19 | ],
20 | },
21 | };
22 |
23 | /**
24 | * Return a random number
25 | * @param alias
26 | * @param param
27 | * @param connection
28 | */
29 | async run(alias: string, param: string, connection: Connection): Promise {
30 | const min = parseInt(param.split(' ')[0]);
31 | const max = parseInt(param.split(' ')[1]);
32 | if (min >= max) {
33 | throw new Error('Invalid min/max values given');
34 | }
35 | const rand = Math.floor(RandomGenerator.random(8) * (1 + max - min) + min);
36 | await this.room.sendMessage({
37 | content: `rand(user=${connection.session.identifier}, min=${min}, max=${max}) = ${rand}`,
38 | user: UserController.getNeutralUser(),
39 | });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/server/plugins/games/room/StatsPlugin.ts:
--------------------------------------------------------------------------------
1 | import { RoomPlugin } from '../../RoomPlugin.js';
2 | import { UserController } from '../../../skychat/UserController.js';
3 | import { User } from '../../../skychat/User.js';
4 | import { Session } from '../../../skychat/Session.js';
5 | import { Config } from '../../../skychat/Config.js';
6 |
7 | export class StatsPlugin extends RoomPlugin {
8 | static readonly AVERAGE_BOOK_READ_TIME: number = 60 * 5;
9 |
10 | static readonly AVERAGE_MOVIE_WATCH_TIME: number = 60 * 2;
11 |
12 | static readonly AVERAGE_MARATHON_RUN_TIME: number = 60 * 4;
13 |
14 | static readonly commandName = 'stats';
15 |
16 | readonly minRight = Config.PREFERENCES.minRightForConnectedList;
17 |
18 | readonly rules = {
19 | stats: {
20 | minCount: 1,
21 | maxCount: 2,
22 | coolDown: 1000,
23 | params: [{ name: 'username', pattern: User.USERNAME_REGEXP }],
24 | },
25 | };
26 |
27 | /**
28 | * Displays a funny message about the number of minutes a user has spent on the tchat
29 | * @param alias
30 | * @param username
31 | * @param connection
32 | */
33 | async run(alias: string, username: string): Promise {
34 | const session = Session.getSessionByIdentifier(username);
35 | if (!session) {
36 | // If user doesn't exist
37 | throw new Error('Username not found');
38 | }
39 |
40 | const xp = session.user.xp;
41 | const minCount = xp;
42 | const hourCount = Math.floor(xp / 60);
43 | const dayCount = Math.floor(xp / 1440);
44 | const weekCount = Math.floor(xp / 10080);
45 | const bookCount = Math.floor(xp / StatsPlugin.AVERAGE_BOOK_READ_TIME);
46 | const movieCount = Math.floor(xp / StatsPlugin.AVERAGE_MOVIE_WATCH_TIME);
47 | const marathonCount = Math.floor(xp / StatsPlugin.AVERAGE_MARATHON_RUN_TIME);
48 |
49 | const messageContent = `${session.user.username} spent ${minCount} ${
50 | minCount > 1 ? 'minutes' : 'minute'
51 | } here, that's ${hourCount} ${hourCount > 1 ? 'hours' : 'hour'}, ${dayCount} ${dayCount > 1 ? 'days' : 'day'} or ${weekCount} ${
52 | weekCount > 1 ? 'weeks' : 'week'
53 | }! During this time, he could have:
54 | - read ${bookCount} ${bookCount > 1 ? 'books' : 'book'} 📖
55 | - watched ${movieCount} ${movieCount > 1 ? 'movies' : 'movie'} 🎥
56 | - run ${marathonCount} ${marathonCount > 1 ? 'marathons' : 'marathon'} 🏃
57 | `;
58 |
59 | await this.room.sendMessage({
60 | content: `${messageContent}`,
61 | user: UserController.getNeutralUser(),
62 | });
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/app/server/plugins/index.ts:
--------------------------------------------------------------------------------
1 | export * from './core/index.js';
2 | export * from './games/index.js';
3 | export * from './gallery/index.js';
4 | export * from './security_extra/index.js';
5 | export * from './user_defined/index.js';
6 | export * from './player/index.js';
7 | export { GlobalPlugin } from './GlobalPlugin.js';
8 | export { RoomPlugin } from './RoomPlugin.js';
9 | export { Plugin } from './Plugin.js';
10 |
--------------------------------------------------------------------------------
/app/server/plugins/player/PlayerPluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { PluginGroup } from '../PluginGroup.js';
2 | import { PlayerPlugin } from './PlayerPlugin.js';
3 | import { YoutubeSearchAndPlayPlugin } from './YoutubeSearchAndPlayPlugin.js';
4 |
5 | export class PlayerPluginGroup extends PluginGroup {
6 | roomPluginClasses = [];
7 |
8 | globalPluginClasses = [PlayerPlugin, YoutubeSearchAndPlayPlugin];
9 | }
10 |
--------------------------------------------------------------------------------
/app/server/plugins/player/YoutubeSearchAndPlayPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../skychat/Connection.js';
2 | import { GlobalPlugin } from '../GlobalPlugin.js';
3 | import { PlayerPlugin } from './PlayerPlugin.js';
4 | import { YoutubeFetcher } from './fetcher/YoutubeFetcher.js';
5 |
6 | /**
7 | *
8 | */
9 | export class YoutubeSearchAndPlayPlugin extends GlobalPlugin {
10 | static readonly commandName = '#';
11 |
12 | readonly rules = {
13 | '#': {
14 | minCount: 1,
15 | maxCallsPer10Seconds: 2,
16 | params: [{ name: 'search', pattern: /./ }],
17 | },
18 | };
19 |
20 | public async run(alias: string, param: string, connection: Connection) {
21 | const plugin = this.manager.getPlugin('player') as PlayerPlugin;
22 | if (!plugin.canAddMedia(connection.session)) {
23 | throw new Error('Unable to perform this action');
24 | }
25 |
26 | const playerPlugin = this.manager.getPlugin('player') as PlayerPlugin;
27 | const channelManager = playerPlugin.channelManager;
28 | const youtubeFetcher = PlayerPlugin.FETCHERS['yt'] as YoutubeFetcher;
29 | const channel = channelManager.getSessionChannel(connection.session);
30 | if (!channel) {
31 | throw new Error('Not in a player channel');
32 | }
33 |
34 | // Search video
35 | const items = await youtubeFetcher.search(playerPlugin, 'video', param, 1);
36 | if (items.length === 0) {
37 | throw new Error('No result found');
38 | }
39 | const videos = await youtubeFetcher.get(playerPlugin, items[0].id);
40 |
41 | // Play video
42 | channel.add(videos, connection.session.user, { allowFailure: false });
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/server/plugins/player/fetcher/GalleryFetcher.ts:
--------------------------------------------------------------------------------
1 | import { GalleryPlugin } from '../../gallery/GalleryPlugin.js';
2 | import { Gallery } from '../../gallery/Gallery.js';
3 | import { VideoInfo } from '../PlayerChannel.js';
4 | import { PlayerPlugin } from '../PlayerPlugin.js';
5 | import { VideoFetcher } from './VideoFetcher.js';
6 |
7 | export class GalleryFetcher implements VideoFetcher {
8 | static readonly ALLOWED_EXTENSIONS: string[] = ['mp4', 'webm'];
9 |
10 | /**
11 | *
12 | */
13 | async getInfoFromLink(playerPlugin: PlayerPlugin, filePath: string): Promise {
14 | // Check that the gallery plugin exists
15 | const galleryPlugin = playerPlugin.manager.getPlugin('gallery') as GalleryPlugin;
16 | if (!galleryPlugin) {
17 | throw new Error('Gallery plugin not found');
18 | }
19 |
20 | // Get file info
21 | const playableFileInfo = await Gallery.getPlayableFileInfo(filePath);
22 |
23 | return {
24 | type: 'gallery',
25 | id: playableFileInfo.url,
26 | title: playableFileInfo.title,
27 | duration: playableFileInfo.duration,
28 | startCursor: 0,
29 | };
30 | }
31 |
32 | /**
33 | * @override
34 | */
35 | async get(playerPlugin: PlayerPlugin, param: string): Promise {
36 | return [await this.getInfoFromLink(playerPlugin, param)];
37 | }
38 |
39 | /**
40 | * @override
41 | */
42 | search(): Promise {
43 | throw new Error('Method not implemented.');
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/server/plugins/player/fetcher/IFrameFetcher.ts:
--------------------------------------------------------------------------------
1 | import { VideoInfo } from '../PlayerChannel.js';
2 | import { PlayerPlugin } from '../PlayerPlugin.js';
3 | import { VideoFetcher } from './VideoFetcher.js';
4 |
5 | export class IFrameFetcher implements VideoFetcher {
6 | static ALLOWED_SOURCES: string[] = [
7 | 'https://w.soundcloud.com',
8 | 'https://airmash.online',
9 | 'https://scdn.nrjaudio.fm',
10 | 'https://bruh.io',
11 | 'https://stream-49.zeno.fm',
12 | 'https://digdig.io',
13 | 'https://streamable.com',
14 | 'https://catbox.moe',
15 | 'https://vocaroo.com',
16 | 'https://voca.ro',
17 | ];
18 |
19 | /**
20 | * @override
21 | */
22 | async get(playerPlugin: PlayerPlugin, src: string): Promise {
23 | // Check if src is an URL starting with one of the list of allowed sources
24 | const isAllowed = !!IFrameFetcher.ALLOWED_SOURCES.find((allowed) => src.startsWith(allowed));
25 | if (!isAllowed) {
26 | throw new Error('Source not allowed. Ask an adminstrator to add it to the list of allowed sources.');
27 | }
28 |
29 | return [
30 | {
31 | type: 'iframe',
32 | id: src,
33 | startCursor: 0,
34 | thumb: '',
35 | duration: 0,
36 | title: src,
37 | },
38 | ];
39 | }
40 |
41 | /**
42 | * @override
43 | */
44 | search(): Promise {
45 | throw new Error('Method not implemented.');
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/server/plugins/player/fetcher/TwitchFetcher.ts:
--------------------------------------------------------------------------------
1 | import { VideoInfo } from '../PlayerChannel.js';
2 | import { PlayerPlugin } from '../PlayerPlugin.js';
3 | import { VideoFetcher } from './VideoFetcher.js';
4 |
5 | //
6 |
7 | export class TwitchFetcher implements VideoFetcher {
8 | async get(playerPlugin: PlayerPlugin, channelName: string): Promise {
9 | channelName = channelName.toLowerCase();
10 | if (!channelName.match(/^[a-z0-9-_]+$/)) {
11 | throw new Error('Invalid channel name');
12 | }
13 | const videoInfo: VideoInfo = {
14 | type: 'twitch',
15 | id: channelName,
16 | duration: 0,
17 | startCursor: 0,
18 | title: `${channelName}'s twitch`,
19 | thumb: 'assets/images/icons/twitch.png',
20 | };
21 | return [videoInfo];
22 | }
23 |
24 | search(): Promise {
25 | throw new Error('Method not implemented.');
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/server/plugins/player/fetcher/VideoFetcher.ts:
--------------------------------------------------------------------------------
1 | import { VideoInfo } from '../PlayerChannel.js';
2 | import { PlayerPlugin } from '../PlayerPlugin.js';
3 |
4 | export interface VideoFetcher {
5 | /**
6 | * Get a media
7 | * @param playerPlugin
8 | * @param param
9 | */
10 | get(playerPlugin: PlayerPlugin, param: string): Promise;
11 |
12 | /**
13 | * Advanced media search
14 | * @param playerPlugin
15 | * @param type
16 | * @param search
17 | * @param limit
18 | */
19 | search(playerPlugin: PlayerPlugin, type: string, search: string, limit: number): Promise;
20 | }
21 |
--------------------------------------------------------------------------------
/app/server/plugins/player/index.ts:
--------------------------------------------------------------------------------
1 | export { PlayerPluginGroup } from './PlayerPluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/BunkerPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../skychat/Connection.js';
2 | import { Message } from '../../skychat/Message.js';
3 | import { RoomManager } from '../../skychat/RoomManager.js';
4 | import { UserController } from '../../skychat/UserController.js';
5 | import { GlobalPlugin } from '../GlobalPlugin.js';
6 |
7 | export class BunkerPlugin extends GlobalPlugin {
8 | static readonly commandName = 'bunker';
9 |
10 | readonly opOnly = true;
11 |
12 | readonly rules = {
13 | bunker: {
14 | minCount: 1,
15 | maxCount: 1,
16 | params: [
17 | {
18 | name: 'mode',
19 | pattern: /^(off|on)$/,
20 | },
21 | ],
22 | },
23 | };
24 |
25 | protected storage: boolean = false;
26 |
27 | constructor(manager: RoomManager) {
28 | super(manager);
29 |
30 | this.loadStorage();
31 | }
32 |
33 | async run(_alias: string, param: string, connection: Connection): Promise {
34 | await this.handleBunker(param, connection);
35 | }
36 |
37 | async handleBunker(param: string, connection: Connection) {
38 | this.storage = param === 'on';
39 | this.syncStorage();
40 |
41 | connection.send(
42 | 'message',
43 | new Message({
44 | content: 'Bunker mode: ' + (this.storage ? 'on' : 'off'),
45 | user: UserController.getNeutralUser(),
46 | }).sanitized(),
47 | );
48 | }
49 |
50 | public async onNewMessageHook(message: string, connection: Connection): Promise {
51 | if (this.storage && connection.session.user.isGuest()) {
52 | return 'Bunker mode enabled. No messages can be sent as guest.';
53 | }
54 | return message;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/ExtraSecurityPluginGroup.ts:
--------------------------------------------------------------------------------
1 | import { PluginGroup } from '../PluginGroup.js';
2 | import { RoomProtectPlugin } from './RoomProtectPlugin.js';
3 | import { HistoryClearPlugin } from './HistoryClearPlugin.js';
4 | import { LogFuzzerPlugin } from './LogFuzzerPlugin.js';
5 | import { TorBanPlugin } from './TorBanPlugin.js';
6 | import { TrackerPlugin } from './TrackerPlugin.js';
7 | import { UsurpPlugin } from './UsurpPlugin.js';
8 | import { BunkerPlugin } from './BunkerPlugin.js';
9 | import { MessageLimiterPlugin } from './MessageLimiterPlugin.js';
10 |
11 | export class ExtraSecurityPluginGroup extends PluginGroup {
12 | roomPluginClasses = [MessageLimiterPlugin, RoomProtectPlugin, HistoryClearPlugin, UsurpPlugin];
13 |
14 | globalPluginClasses = [BunkerPlugin, LogFuzzerPlugin, TorBanPlugin, TrackerPlugin];
15 | }
16 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/HistoryClearPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from '../../skychat/Connection.js';
2 | import { UserController } from '../../skychat/UserController.js';
3 | import { RoomPlugin } from '../RoomPlugin.js';
4 |
5 | export class HistoryClearPlugin extends RoomPlugin {
6 | static readonly commandName = 'historyclear';
7 |
8 | static readonly commandAliases = ['hc'];
9 |
10 | readonly opOnly = true;
11 |
12 | readonly rules = {
13 | historyclear: { coolDown: 10000 },
14 | hc: { coolDown: 10000 },
15 | };
16 |
17 | async run(alias: string, param: string, connection: Connection): Promise {
18 | await UserController.buy(connection.session.user, 100);
19 | this.room.clearHistory();
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/LogFuzzerPlugin.ts:
--------------------------------------------------------------------------------
1 | import SQL from 'sql-template-strings';
2 | import { Config } from '../../skychat/Config.js';
3 | import { DatabaseHelper } from '../../skychat/DatabaseHelper.js';
4 | import { Logging } from '../../skychat/Logging.js';
5 | import { RoomManager } from '../../skychat/RoomManager.js';
6 | import { GlobalPlugin } from '../GlobalPlugin.js';
7 |
8 | export class LogFuzzerPlugin extends GlobalPlugin {
9 | static readonly DURATION_BEFORE_FUZZ = Config.PREFERENCES.daysBeforeMessageFuzz * 24 * 60 * 60 * 1000;
10 |
11 | static readonly FUZZ_COOLDOWN = Math.min(LogFuzzerPlugin.DURATION_BEFORE_FUZZ, 7 * 24 * 60 * 60 * 1000);
12 |
13 | static readonly commandName = 'logfuzzer';
14 |
15 | readonly callable = false;
16 |
17 | readonly hidden = true;
18 |
19 | /**
20 | * Last fuzzed message id in history
21 | */
22 | protected storage: { lastId: number } = { lastId: 0 };
23 |
24 | private tickTimeout?: NodeJS.Timeout;
25 |
26 | constructor(manager: RoomManager) {
27 | super(manager);
28 |
29 | this.loadStorage();
30 | this.armTick(LogFuzzerPlugin.FUZZ_COOLDOWN);
31 | }
32 |
33 | async run(): Promise {
34 | throw new Error('Not implemented');
35 | }
36 |
37 | private fuzzContent(content: string) {
38 | return content.replace(/(^| |,|\/|.)([a-z0-9àâçéèêëîïôûùüÿñæœ-]+)/gi, (m0, m1, m2) => {
39 | let newStr = '';
40 | for (const char of m2) {
41 | if (char === char.toUpperCase()) {
42 | newStr += 'A';
43 | } else {
44 | newStr += 'a';
45 | }
46 | }
47 | return m1 + newStr;
48 | });
49 | }
50 |
51 | private armTick(duration: number) {
52 | this.tickTimeout && clearTimeout(this.tickTimeout);
53 | this.tickTimeout = setTimeout(this.tick.bind(this), duration);
54 | }
55 |
56 | async tick(): Promise {
57 | const limitTimestamp = Math.floor(new Date().getTime() - LogFuzzerPlugin.DURATION_BEFORE_FUZZ);
58 | Logging.info('Fuzzing messages before', new Date(limitTimestamp).toISOString());
59 | const sqlQuery = SQL`select id, content from messages where id > ${this.storage.lastId} and date <= ${new Date(
60 | limitTimestamp,
61 | ).toISOString()} limit 5000`;
62 | const messages: { content: string; id: number }[] = (await DatabaseHelper.db.query(sqlQuery)).rows;
63 | for (const { id, content } of messages) {
64 | const sqlQuery = SQL`update messages set content=${this.fuzzContent(content)} where id=${id}`;
65 | await DatabaseHelper.db.query(sqlQuery);
66 | }
67 | if (messages.length > 0) {
68 | const maxId = Math.max(...messages.map((message) => message.id));
69 | this.storage.lastId = maxId;
70 | this.syncStorage();
71 | }
72 | this.armTick(LogFuzzerPlugin.FUZZ_COOLDOWN);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/RoomProtectPlugin.ts:
--------------------------------------------------------------------------------
1 | import { UserController } from '../../skychat/UserController.js';
2 | import { RoomPlugin } from '../RoomPlugin.js';
3 | import { Room } from '../../skychat/Room.js';
4 | import { Connection } from '../../skychat/Connection.js';
5 |
6 | export class RoomProtectPlugin extends RoomPlugin {
7 | static readonly commandName = 'roomprotect';
8 |
9 | readonly opOnly = true;
10 |
11 | readonly rules = {
12 | roomprotect: {
13 | minCount: 1,
14 | maxCount: 3,
15 | params: [
16 | {
17 | name: 'protection',
18 | pattern: /^(off|\d+)$/,
19 | },
20 | ],
21 | },
22 | };
23 |
24 | /**
25 | * Min right to enter the room
26 | */
27 | protected storage: null | number = null;
28 |
29 | constructor(room: Room) {
30 | super(room);
31 |
32 | this.loadStorage();
33 | }
34 |
35 | async run(_alias: string, param: string): Promise {
36 | await this.handleRoomProtect(param);
37 | }
38 |
39 | public getMinRight() {
40 | return this.storage === null ? -1 : this.storage;
41 | }
42 |
43 | public getRoomSummary() {
44 | return this.storage;
45 | }
46 |
47 | async handleRoomProtect(param: string) {
48 | if (param === 'off') {
49 | this.storage = null;
50 | } else if (!isNaN(parseInt(param, 10))) {
51 | this.storage = parseInt(param, 10);
52 | }
53 | this.syncStorage();
54 |
55 | this.room.sendMessage({
56 | content: 'Room as a new protection policy: ' + (this.storage === null ? 'No protection' : `Min right: ${this.storage}`),
57 | user: UserController.getNeutralUser(),
58 | });
59 | }
60 |
61 | public async onBeforeConnectionJoinedRoom(connection: Connection): Promise {
62 | // If bunker mode not enabled, do nothing
63 | if (this.storage === null) {
64 | return;
65 | }
66 |
67 | if (connection.session.isOP()) {
68 | return;
69 | }
70 |
71 | if (connection.session.user.right >= this.storage) {
72 | return;
73 | }
74 |
75 | throw new Error('You do not have enough right to enter this room');
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/TorBanPlugin.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { Connection } from '../../skychat/Connection.js';
3 | import { Logging } from '../../skychat/Logging.js';
4 | import { RoomManager } from '../../skychat/RoomManager.js';
5 | import { GlobalPlugin } from '../GlobalPlugin.js';
6 |
7 | export class TorBanPlugin extends GlobalPlugin {
8 | static readonly CHECK_TOR_URL = 'https://check.torproject.org/torbulkexitlist';
9 |
10 | static readonly UPDATE_TOR_EXIT_NODES_INTERVAL = 6 * 60 * 60 * 1000;
11 |
12 | static readonly commandName = 'torban';
13 |
14 | public torExitNodes: string[] = [];
15 |
16 | readonly callable = false;
17 |
18 | public readonly opOnly = true;
19 |
20 | public readonly hidden = true;
21 |
22 | constructor(manager: RoomManager) {
23 | super(manager);
24 |
25 | setInterval(this.updateTorExitNodesList.bind(this), TorBanPlugin.UPDATE_TOR_EXIT_NODES_INTERVAL);
26 | this.updateTorExitNodesList();
27 | }
28 |
29 | public run(): Promise {
30 | throw new Error('Method not implemented.');
31 | }
32 |
33 | private async updateTorExitNodesList(): Promise {
34 | try {
35 | const { data: text } = await axios.request({
36 | method: 'GET',
37 | url: TorBanPlugin.CHECK_TOR_URL,
38 | responseType: 'text',
39 | });
40 | const ips = text.trim().split('\n');
41 | this.torExitNodes = ips;
42 | } catch (error) {
43 | Logging.warn(error);
44 | }
45 | }
46 |
47 | public async onBeforeConnectionJoinedRoom(connection: Connection): Promise {
48 | if (this.torExitNodes.indexOf(connection.ip) >= 0) {
49 | connection.close();
50 | throw new Error('Error');
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/UsurpPlugin.ts:
--------------------------------------------------------------------------------
1 | import { User } from '../../skychat/User.js';
2 | import { Session } from '../../skychat/Session.js';
3 | import { RoomPlugin } from '../RoomPlugin.js';
4 |
5 | /**
6 | *
7 | */
8 | export class UsurpPlugin extends RoomPlugin {
9 | static readonly commandName = 'usurp';
10 |
11 | readonly opOnly = true;
12 |
13 | readonly rules = {
14 | usurp: {
15 | minCount: 2,
16 | params: [
17 | { name: 'username', pattern: User.USERNAME_REGEXP },
18 | { name: 'command', pattern: /./ },
19 | ],
20 | },
21 | };
22 |
23 | async run(alias: string, param: string): Promise {
24 | const identifier = param.split(' ')[0].toLowerCase();
25 | const commandName = param.split(' ')[1];
26 | const session = Session.getSessionByIdentifier(identifier);
27 | if (!session || session.connections.length === 0) {
28 | throw new Error('User ' + identifier + ' does not exist');
29 | }
30 | const command = this.room.getPlugin(commandName) || this.room.manager.getPlugin(commandName);
31 | if (!command) {
32 | throw new Error(`Command ${commandName} does not exist`);
33 | }
34 | await command.run(commandName, param.split(' ').slice(2).join(' '), session.connections[0], session, session.user, this.room);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/server/plugins/security_extra/index.ts:
--------------------------------------------------------------------------------
1 | export { ExtraSecurityPluginGroup } from './ExtraSecurityPluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/plugins/user_defined/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !index.ts
3 | !.gitignore
4 | !UserDefinedPluginGroup.ts
5 |
--------------------------------------------------------------------------------
/app/server/plugins/user_defined/UserDefinedPluginGroup.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import { RoomPlugin, GlobalPlugin } from '../index.js';
3 | import { PluginGroup } from '../PluginGroup.js';
4 | import path from 'path';
5 | import { fileURLToPath } from 'url';
6 |
7 | const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
8 | const __dirname = path.dirname(__filename); // get the name of the directory
9 |
10 | // Find all plugins in this directory
11 | const pluginClasses: any[] = fs
12 | .readdirSync(__dirname)
13 | .map((fileName: string) => {
14 | if (!fileName.match(/^(\.d)\.(ts|js)$/)) {
15 | return null;
16 | }
17 | // Require filename
18 | const loadedFile = require(`./${fileName}`);
19 | const defaultExport = loadedFile.default || null;
20 | if (!defaultExport) {
21 | return null;
22 | }
23 | if (!defaultExport.commandName) {
24 | return null;
25 | }
26 | return defaultExport;
27 | })
28 | .filter((PluginConstr: any) => !!PluginConstr);
29 |
30 | export class UserDefinedPluginGroup extends PluginGroup {
31 | roomPluginClasses = pluginClasses.filter((c: any) => c.prototype instanceof RoomPlugin);
32 | globalPluginClasses = pluginClasses.filter((c: any) => c.prototype instanceof GlobalPlugin);
33 | }
34 |
--------------------------------------------------------------------------------
/app/server/plugins/user_defined/index.ts:
--------------------------------------------------------------------------------
1 | export { UserDefinedPluginGroup } from './UserDefinedPluginGroup.js';
2 |
--------------------------------------------------------------------------------
/app/server/server.ts:
--------------------------------------------------------------------------------
1 | import { DatabaseHelper } from './skychat/DatabaseHelper.js';
2 | import { Logging } from './skychat/Logging.js';
3 | import { SkyChatServer } from './skychat/SkyChatServer.js';
4 |
5 | (async () => {
6 | Logging.info('Loading database');
7 | await DatabaseHelper.load();
8 |
9 | Logging.info('Starting server');
10 | const skyChatServer = new SkyChatServer();
11 | skyChatServer.start();
12 | })();
13 |
--------------------------------------------------------------------------------
/app/server/skychat/DatabaseHelper.ts:
--------------------------------------------------------------------------------
1 | import pg from 'pg';
2 |
3 | const INSTALL_QUERY = `
4 | CREATE TABLE IF NOT EXISTS users (
5 | id SERIAL PRIMARY KEY,
6 | username varchar(32) NOT NULL,
7 | username_custom varchar(32) NOT NULL,
8 | email varchar(128),
9 | password varchar(256) NOT NULL,
10 | money int NOT NULL,
11 | xp int NOT NULL,
12 | "right" int NOT NULL,
13 | data varchar(4096) NOT NULL,
14 | storage text DEFAULT '{}' NOT NULL,
15 | tms_created int NOT NULL,
16 | tms_last_seen int NOT NULL,
17 | CONSTRAINT username_unique UNIQUE(username)
18 | );
19 |
20 | CREATE TABLE IF NOT EXISTS messages (
21 | id SERIAL PRIMARY KEY,
22 | room_id INTEGER NOT NULL,
23 | user_id INTEGER NOT NULL,
24 | quoted_message_id INTEGER DEFAULT NULL,
25 | content text NOT NULL,
26 | date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
27 | ip text DEFAULT NULL,
28 | storage text DEFAULT '{}' NOT NULL
29 | );
30 |
31 | -- Add index on room_id and id in descending order, if it doesn't exist
32 | CREATE INDEX IF NOT EXISTS room_id_id_index ON messages (room_id, id DESC);
33 | `;
34 |
35 | /**
36 | * Helper class to interact with the database
37 | */
38 | export class DatabaseHelper {
39 | /**
40 | * DB instance
41 | */
42 | static db: pg.Client;
43 |
44 | /**
45 | * Connect to the database
46 | */
47 | static async load(): Promise {
48 | const db = new pg.Client({
49 | user: process.env.POSTGRES_USER,
50 | password: process.env.POSTGRES_PASSWORD,
51 | database: process.env.POSTGRES_DB,
52 | host: 'skychat_db',
53 | port: 5432,
54 | });
55 | await db.connect();
56 | await db.query(INSTALL_QUERY);
57 |
58 | DatabaseHelper.db = db;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/server/skychat/IBroadcaster.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A broadcaster is an object that can send events to one or multiple connections
3 | */
4 | export interface IBroadcaster {
5 | send(event: string, payload: any): void;
6 | }
7 |
--------------------------------------------------------------------------------
/app/server/skychat/Logging.ts:
--------------------------------------------------------------------------------
1 | import pino from 'pino';
2 |
3 | export class Logging {
4 | private static readonly logger: pino.Logger = pino();
5 |
6 | private static argsToString(args: unknown[]): string {
7 | return args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
8 | }
9 |
10 | static info(...messages: unknown[]) {
11 | Logging.logger.info(Logging.argsToString(messages));
12 | }
13 |
14 | static error(...messages: unknown[]) {
15 | Logging.logger.error(Logging.argsToString(messages));
16 | }
17 |
18 | static warn(...messages: unknown[]) {
19 | Logging.logger.warn(Logging.argsToString(messages));
20 | }
21 |
22 | static debug(...messages: unknown[]) {
23 | Logging.logger.debug(Logging.argsToString(messages));
24 | }
25 |
26 | static trace(...messages: unknown[]) {
27 | Logging.logger.trace(Logging.argsToString(messages));
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/server/skychat/RandomGenerator.biguint-format.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'biguint-format';
2 |
--------------------------------------------------------------------------------
/app/server/skychat/RandomGenerator.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from 'crypto';
2 | import biguint from 'biguint-format';
3 |
4 | export class RandomGenerator {
5 | /**
6 | *
7 | * @param bytes Number of bytes to use to generate the number between 0 and 1. The number of distinct values
8 | * that this function can return is equal to 255^bytes.
9 | */
10 | public static random(bytes: number): number {
11 | bytes = bytes || 4;
12 | return biguint(crypto.randomBytes(bytes), 'dec') / Math.pow(256, bytes);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/app/server/skychat/RateLimiter.ts:
--------------------------------------------------------------------------------
1 | import http from 'http';
2 | import { RateLimiterMemory } from 'rate-limiter-flexible';
3 |
4 | export class RateLimiter {
5 | static readonly DEFAULT_IP_HEADER = 'x-forwarded-for';
6 |
7 | static getIP(request: http.IncomingMessage): string {
8 | const trustedHeaderName = process.env.TRUSTED_IP_HEADER?.toLowerCase() || RateLimiter.DEFAULT_IP_HEADER;
9 | const trustedHeaderIp = trustedHeaderName ? request.headers[trustedHeaderName] : null;
10 | if (trustedHeaderName && trustedHeaderIp) {
11 | if (Array.isArray(trustedHeaderIp)) {
12 | return trustedHeaderIp[0] ?? request.socket.remoteAddress ?? 'unknown';
13 | } else {
14 | return trustedHeaderIp;
15 | }
16 | }
17 | return request.socket.remoteAddress ?? 'unknown';
18 | }
19 |
20 | /**
21 | * Rate limit without risking leaking information on error
22 | */
23 | static async rateLimitSafe(rateLimiter: RateLimiterMemory, key: string, pointsToConsume?: number) {
24 | try {
25 | await rateLimiter.consume(key, pointsToConsume);
26 | } catch (error) {
27 | throw new Error(`Rate limit check failed`);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/server/skychat/ShellHelper.ts:
--------------------------------------------------------------------------------
1 | import { exec } from 'child_process';
2 |
3 | export class ShellHelper {
4 | static exec(cmd: string): Promise<{ stdout: string; stderr: string }> {
5 | return new Promise((resolve, reject) => {
6 | exec(cmd, (err, stdout, stderr) => {
7 | if (err) {
8 | reject(new Error(stdout));
9 | } else {
10 | resolve({ stdout, stderr });
11 | }
12 | });
13 | });
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/server/skychat/SkyChatServer.ts:
--------------------------------------------------------------------------------
1 | import { AuthBridge, ConnectionAcceptedEvent } from './AuthBridge.js';
2 | import { Config } from './Config.js';
3 | import { Connection } from './Connection.js';
4 | import { HttpServer } from './HttpServer.js';
5 | import { Logging } from './Logging.js';
6 | import { PluginManager } from './PluginManager.js';
7 | import { RoomManager } from './RoomManager.js';
8 | import { Session } from './Session.js';
9 | import { UserController } from './UserController.js';
10 |
11 | export class SkyChatServer {
12 | private static CURRENT_GUEST_ID = 0;
13 |
14 | private readonly httpServer: HttpServer;
15 |
16 | private readonly authBridge: AuthBridge;
17 |
18 | private readonly pluginManager: PluginManager;
19 |
20 | private readonly roomManager: RoomManager;
21 |
22 | constructor() {
23 | this.httpServer = new HttpServer();
24 | this.authBridge = new AuthBridge(this.httpServer);
25 | this.pluginManager = new PluginManager();
26 | this.roomManager = new RoomManager(this.pluginManager);
27 | }
28 |
29 | start() {
30 | Logging.info('Starting services');
31 | this.httpServer.start();
32 | this.authBridge.start();
33 | this.pluginManager.start(this.roomManager);
34 | this.roomManager.start();
35 |
36 | this.authBridge.on('connection-accepted', this.onConnectionAccepted.bind(this));
37 | }
38 |
39 | private async onConnectionAccepted(event: ConnectionAcceptedEvent) {
40 | try {
41 | Logging.info('Connection accepted', event.user ? event.user.username : '*guest');
42 | const session = event.user
43 | ? await UserController.getOrCreateUserSession(event.user, event.data.credentials?.username)
44 | : await this.getNewGuestSession();
45 |
46 | // Create a new connection object & attach it to the session
47 | const connection = new Connection(session, event.webSocket, event.request);
48 |
49 | // Send config (it should be the first message sent to the client)
50 | connection.send('config', Config.toClient());
51 |
52 | // Notify the room manager & plugin manager
53 | this.roomManager.onConnectionCreated(connection);
54 | await this.pluginManager.onConnectionCreated(connection, event);
55 | } catch (error) {
56 | Logging.error(error);
57 | event.webSocket.send(
58 | JSON.stringify({
59 | event: 'error',
60 | data: `${error}`,
61 | }),
62 | );
63 | return;
64 | }
65 | }
66 |
67 | /**
68 | * Build a new session object when there is a new connection
69 | */
70 | private async getNewGuestSession(): Promise {
71 | // Guest session
72 | if (SkyChatServer.CURRENT_GUEST_ID >= Math.pow(10, 10)) {
73 | SkyChatServer.CURRENT_GUEST_ID = 0;
74 | }
75 | const guestId = ++SkyChatServer.CURRENT_GUEST_ID;
76 | const randomName = Config.getRandomGuestName();
77 | const identifier = '*' + randomName + '#' + guestId;
78 | const session = new Session(identifier);
79 | session.user.data.plugins.avatar = 'https://eu.ui-avatars.com/api/?name=' + randomName;
80 | return session;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/server/skychat/StickerManager.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import { Logging } from './Logging.js';
3 |
4 | /**
5 | * Manages stickers
6 | */
7 | export class StickerManager {
8 | public static readonly STICKERS_JSON: string = 'config/stickers.json';
9 |
10 | public static readonly STICKER_CODE_REGEXP: RegExp = /^:([a-z0-9-_)(]+):?$/;
11 |
12 | public static stickers: { [code: string]: string } = {};
13 |
14 | constructor() {
15 | throw new Error('Utility class');
16 | }
17 |
18 | /**
19 | * Load stickers from storage
20 | */
21 | public static loadStickers(): void {
22 | try {
23 | this.stickers = JSON.parse(fs.readFileSync(StickerManager.STICKERS_JSON).toString());
24 | } catch (e) {
25 | Logging.warn('stickers.json did NOT exist. It has been created automatically.');
26 | this.stickers = {};
27 | this.saveStickers();
28 | }
29 | }
30 |
31 | /**
32 | * Add a sticker
33 | * @param code
34 | * @param url
35 | */
36 | public static registerSticker(code: string, url: string): void {
37 | code = code.toLowerCase();
38 | if (typeof this.stickers[code] !== 'undefined') {
39 | throw new Error('This sticker already exist');
40 | }
41 | this.stickers[code] = url;
42 | this.saveStickers();
43 | }
44 |
45 | /**
46 | * Whether a sticker code is defined
47 | * @param code
48 | * @returns
49 | */
50 | public static stickerExists(code: string): boolean {
51 | return typeof this.stickers[code.toLowerCase()] !== 'undefined';
52 | }
53 |
54 | /**
55 | * Delete a sticker
56 | * @param code
57 | */
58 | public static unregisterSticker(code: string): void {
59 | code = code.toLowerCase();
60 | if (typeof this.stickers[code] === 'undefined') {
61 | throw new Error('This sticker does not exist');
62 | }
63 | delete this.stickers[code];
64 | this.saveStickers();
65 | }
66 |
67 | /**
68 | * Get a sticker URL
69 | * @param code
70 | * @returns
71 | */
72 | public static getStickerUrl(code: string): string {
73 | return this.stickers[code];
74 | }
75 |
76 | /**
77 | * Save current sticker list to storage
78 | */
79 | public static saveStickers(): void {
80 | fs.writeFileSync(StickerManager.STICKERS_JSON, JSON.stringify(this.stickers));
81 | }
82 | }
83 |
84 | StickerManager.loadStickers();
85 |
--------------------------------------------------------------------------------
/app/server/skychat/Timing.ts:
--------------------------------------------------------------------------------
1 | export class Timing {
2 | /**
3 | * Pretty print a given duration in milliseconds
4 | * @param durationMs
5 | * @param onlyHighestMagnitude
6 | * @param shortNames
7 | */
8 | static getDurationText(durationMs: number, onlyHighestMagnitude?: boolean, shortNames?: boolean): string {
9 | const durationDays = durationMs / 1000 / 60 / 60 / 24;
10 | const days = Math.floor(durationDays);
11 | const durationHours = (durationDays - days) * 24;
12 | const hours = Math.floor(durationHours);
13 | const durationMinutes = (durationHours - hours) * 60;
14 | const minutes = Math.floor(durationMinutes);
15 | const durationSeconds = (durationMinutes - minutes) * 60;
16 | const seconds = Math.floor(durationSeconds);
17 |
18 | const longNames: any = {
19 | d: { singular: ' day', plural: ' days' },
20 | h: { singular: ' hour', plural: ' hours' },
21 | m: { singular: ' minute', plural: ' minutes' },
22 | s: { singular: ' day', plural: ' day' },
23 | };
24 |
25 | const durations = (
26 | [
27 | ['d', days],
28 | ['h', hours],
29 | ['m', minutes],
30 | ['s', seconds],
31 | ] as [string, number][]
32 | )
33 | .filter((entry) => entry[1] > 0)
34 | .map(
35 | (entry) => `${entry[1]}${shortNames ? entry[0] : entry[1] > 1 ? longNames[entry[0]].plural : longNames[entry[0]].singular}`,
36 | );
37 |
38 | if (durations.length === 0) {
39 | return 'now';
40 | }
41 |
42 | if (onlyHighestMagnitude) {
43 | return durations[0];
44 | }
45 |
46 | return durations.join(' ');
47 | }
48 |
49 | /**
50 | * Sleep for a given duration in milliseconds
51 | * @param delay
52 | * @returns
53 | */
54 | static sleep(delay: number): Promise {
55 | return new Promise((resolve) => {
56 | setTimeout(resolve, delay);
57 | });
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/static/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginxinc/nginx-unprivileged:alpine3.21-perl
2 | EXPOSE 80
3 |
4 | COPY app/static/nginx.default.conf /etc/nginx/conf.d/default.conf
5 |
6 | CMD ["nginx", "-g", "daemon off;"]
7 |
--------------------------------------------------------------------------------
/app/static/nginx.default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html;
7 |
8 | # Deny access to hidden files (.htaccess, .git, etc.)
9 | location ~ /\. {
10 | deny all;
11 | access_log off;
12 | log_not_found off;
13 | }
14 |
15 | # Serve static files and fallback to index.html
16 | location / {
17 | try_files $uri $uri/ /index.html;
18 | }
19 |
20 | # Add basic security headers
21 | add_header X-XSS-Protection "1; mode=block" always;
22 | add_header Referrer-Policy "no-referrer-when-downgrade" always;
23 | add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none'; frame-src *;" always;
24 |
25 | client_max_body_size 1M;
26 | server_tokens off;
27 | }
28 |
--------------------------------------------------------------------------------
/app/template/.env.template:
--------------------------------------------------------------------------------
1 | # Password salt. Use something random, long and keep it secret.
2 | # Recommended: `tr -dc 'A-Za-z0-9!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' ?@[\]^_`{|}~' `
71 | MAILGUN_FROM=
72 |
73 | # Admin file browser
74 | ADMIN_FILEBROWSER_ENABLED=false # ⚠️ Set a value for ADMIN_FILEBROWSER_AUTH if enabled, otherwise all uploads will be public
75 | ADMIN_FILEBROWSER_HOST=filebrowser.admin.skych.at.localhost
76 | ADMIN_FILEBROWSER_AUTH="" # Basic auth (`htpasswd -nb user password`)
77 |
78 | # Youtube API key. If unset, you will not be able to use the Youtube plugin (search for YouTube videos).
79 | # See [the guide](./app/doc/setup-youtube.md) to get a Youtube API key.
80 | YOUTUBE_API_KEY=
81 |
82 | # Enabled plugin groups. By default, all are enabled. To disable a plugin group, remove it from the list.
83 | ENABLED_PLUGINS="CorePluginGroup,GamesPluginGroup,ExtraSecurityPluginGroup,PlayerPluginGroup,GalleryPluginGroup,UserDefinedPluginGroup"
84 |
--------------------------------------------------------------------------------
/app/template/fakemessages.txt.template:
--------------------------------------------------------------------------------
1 | Oh, Hi Marc!
2 | Hi guys
3 | lmao
4 | ok
5 | nice weather today
6 | interesting
7 | Wait is anyone here?
8 | Ah, I did not know that!
9 | Stronger than mister
10 | Let's do it guys, I'm in
11 | Need to go brb
12 | Afk real quick
13 | Nice
14 | K
--------------------------------------------------------------------------------
/app/template/preferences.json.template:
--------------------------------------------------------------------------------
1 | {
2 | "minRightForMessageHistory": -1,
3 | "minRightForShortTermMessageHistory": -1,
4 | "minRightForPublicMessages": -1,
5 | "minRightForPrivateMessages": -1,
6 | "minRightForMessageQuoting": -1,
7 | "minRightForUserMention": -1,
8 | "minRightForUserModeration": "op",
9 | "minRightForSetRight": "op",
10 | "minRightForAudioRecording": -1,
11 | "minRightForConnectedList": -1,
12 | "minRightForPolls": -1,
13 | "minRightForGalleryRead": 0,
14 | "minRightForGalleryWrite": "op",
15 | "minRightForGalleryDelete": "op",
16 | "minRightForPlayerAddMedia": 0,
17 | "minRightForPlayerManageSchedule": "op",
18 | "maxReplacedImagesPerMessage": 50,
19 | "maxReplacedStickersPerMessage": 50,
20 | "maxReplacedRisiBankStickersPerMessage": 4,
21 | "maxNewlinesPerMessage": 20,
22 | "maxConsecutiveMessages": 1,
23 | "maxMessageMergeDelayMin": 10,
24 | "daysBeforeMessageFuzz": 7,
25 | "invertedBlacklist": false,
26 | "messagesCooldown": [[-1, 1]]
27 | }
28 |
--------------------------------------------------------------------------------
/app/template/stickers.json.template:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/app/template/welcome.txt.template:
--------------------------------------------------------------------------------
1 | Welcome on the SkyChat!
2 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@babel/preset-typescript',
4 | [
5 | '@babel/preset-env',
6 | {
7 | targets: {
8 | node: 'current',
9 | },
10 | },
11 | ],
12 | ],
13 | }
14 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 | skychat_traefik:
3 | ports:
4 | - 8085:8080
5 | environment:
6 | - MODE=DEVELOPMENT
7 | skychat_frontend:
8 | ports:
9 | - 5173:5173
10 | build:
11 | context: .
12 | dockerfile: app/client/Dockerfile-dev
13 | volumes:
14 | - ./app:/workdir/app:ro
15 | skychat_backend:
16 | build:
17 | context: .
18 | dockerfile: app/server/Dockerfile-dev
19 | volumes:
20 | - ./app:/workdir/app:ro
21 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./app/client/index.html', './app/client/src/**/*.{vue,js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {
6 | screens: {
7 | max: '1640px',
8 | },
9 | colors: {
10 | // Shades of gray
11 | 'skygray-white': 'rgb(var(--color-skygray-white) / )',
12 | 'skygray-lightest': 'rgb(var(--color-skygray-lightest) / )',
13 | 'skygray-lighter': 'rgb(var(--color-skygray-lighter) / )',
14 | 'skygray-light': 'rgb(var(--color-skygray-light) / )',
15 | 'skygray-casual': 'rgb(var(--color-skygray-casual) / )',
16 | 'skygray-dark': 'rgb(var(--color-skygray-dark) / )',
17 | 'skygray-darker': 'rgb(var(--color-skygray-darker) / )',
18 | 'skygray-black': 'rgb(var(--color-skygray-black) / )',
19 |
20 | // Primary/Secondary/Tertiary colors
21 | primary: 'rgb(var(--color-primary) / )',
22 | 'primary-light': 'rgb(var(--color-primary-light) / )',
23 | secondary: 'rgb(var(--color-secondary) / )',
24 | 'secondary-light': 'rgb(var(--color-secondary-light) / )',
25 | tertiary: 'rgb(var(--color-tertiary) / )',
26 | 'tertiary-light': 'rgb(var(--color-tertiary-light) / )',
27 |
28 | // Info/Warn/Danger colors
29 | info: 'rgb(var(--color-info) / )',
30 | 'info-light': 'rgb(var(--color-info-light) / )',
31 | warn: 'rgb(var(--color-warn) / )',
32 | 'warn-light': 'rgb(var(--color-warn-light) / )',
33 | danger: 'rgb(var(--color-danger) / )',
34 | 'danger-light': 'rgb(var(--color-danger-light) / )',
35 | },
36 | },
37 | },
38 | plugins: [],
39 | };
40 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "module": "ES2020",
5 | "moduleResolution": "Node",
6 | "strict": true,
7 | "skipLibCheck": true,
8 | "rootDir": "app",
9 | "outDir": "build",
10 | "esModuleInterop": true,
11 | "declaration": true
12 | },
13 | "include": [
14 | "app/server/**/*.ts",
15 | "app/api/**/*.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import vue from '@vitejs/plugin-vue';
2 | import path from 'path';
3 | import { defineConfig } from 'vite';
4 |
5 | export default defineConfig({
6 | root: 'app/client/',
7 | build: {
8 | outDir: '../../dist',
9 | emptyOutDir: true,
10 | },
11 | plugins: [vue()],
12 | resolve: {
13 | alias: {
14 | '@': path.resolve(__dirname, './app/client/src'),
15 | },
16 | },
17 | envPrefix: ['VAPID_PUBLIC_', 'VITE_'],
18 | server: {
19 | port: 80,
20 | host: '0.0.0.0',
21 | },
22 | });
23 |
--------------------------------------------------------------------------------