├── .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 | 2 | 3 | 8 | 9 | 10 | 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 | 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 | 25 | -------------------------------------------------------------------------------- /app/client/src/components/common/SkyDropdownItem.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /app/client/src/components/common/SkyTooltip.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /app/client/src/components/gallery/GalleryFileDotMenu.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 63 | 64 | 77 | -------------------------------------------------------------------------------- /app/client/src/components/layout/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 69 | 70 | 78 | -------------------------------------------------------------------------------- /app/client/src/components/message/MessageReaction.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 38 | -------------------------------------------------------------------------------- /app/client/src/components/message/MessageReactionAdd.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /app/client/src/components/message/MessageReactions.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 39 | -------------------------------------------------------------------------------- /app/client/src/components/modal/GalleryModal.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /app/client/src/components/modal/ManageRoomsModal.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 39 | -------------------------------------------------------------------------------- /app/client/src/components/modal/ModalTemplate.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 39 | 40 | 53 | -------------------------------------------------------------------------------- /app/client/src/components/modal/OngoingConvertsModal.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 36 | -------------------------------------------------------------------------------- /app/client/src/components/modal/PlayerQueueModal.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 33 | -------------------------------------------------------------------------------- /app/client/src/components/modal/ProfileModal.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 66 | -------------------------------------------------------------------------------- /app/client/src/components/player/MediaPlayer.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/client/src/components/player/impl/GalleryPlayer.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/client/src/components/player/impl/IFramePlayer.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/client/src/components/player/impl/TwitchPlayer.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/client/src/components/player/impl/YoutubePlayer.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/client/src/components/playerchannel/PlayerChannelList.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /app/client/src/components/playerchannel/SinglePlayerChannel.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/client/src/components/poll/PollList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/client/src/components/room/RoomList.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 41 | -------------------------------------------------------------------------------- /app/client/src/components/user/ConnectedList.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /app/client/src/components/user/UserBigAvatar.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/client/src/components/user/UserMiniAvatar.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/client/src/components/user/UserMiniAvatarCollection.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/client/src/components/util/ExpandableBlock.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 56 | 57 | 69 | -------------------------------------------------------------------------------- /app/client/src/components/util/HoverCard.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 55 | 56 | 88 | -------------------------------------------------------------------------------- /app/client/src/components/util/SectionSubTitle.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /app/client/src/components/util/SectionTitle.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 21 | 22 | 23 | 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 | 47 | 48 | 49 | 50 | 51 | `; 52 | } 53 | content += '
namemin rightcooldownparams
${alias}${command.minRight}${coolDown}s${(rules.params ?? []).map((param) => param.name).join(', ')}
'; 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 | --------------------------------------------------------------------------------