├── temp └── .keep ├── .gitignore ├── client ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── index.html │ └── sw.js ├── src │ ├── pages │ │ ├── styles │ │ │ ├── Send.scss │ │ │ └── Folder.scss │ │ ├── Home.js │ │ ├── Receive.js │ │ ├── Room.js │ │ ├── Folder.js │ │ └── Send.js │ ├── styles │ │ ├── _variables.scss │ │ └── App.scss │ ├── components │ │ ├── Center.js │ │ ├── styles │ │ │ ├── Center.scss │ │ │ ├── UserDisplay.scss │ │ │ ├── Camera.scss │ │ │ ├── Navbar.scss │ │ │ └── Loader.scss │ │ ├── Loader.js │ │ ├── Button.js │ │ ├── Qr.js │ │ ├── UserDisplay.js │ │ ├── Navbar.js │ │ └── Camera.js │ ├── stores │ │ ├── pageStore.js │ │ └── userStore.js │ ├── utils │ │ └── config.js │ ├── services │ │ └── WebSocketService.js │ ├── index.js │ └── App.js └── package.json ├── routes ├── userRoute.js ├── socketRoute.js ├── sendRoute.js └── folderRoute.js ├── package.json ├── README.md ├── Dockerfile ├── LICENSE ├── utils ├── googleStorage.js └── network.js └── server.js /temp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | 4 | *.css 5 | *.css.map 6 | test.js 7 | skrin-sementara-bucket -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adibzter/sementara/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adibzter/sementara/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adibzter/sementara/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /client/src/pages/styles/Send.scss: -------------------------------------------------------------------------------- 1 | #send-page { 2 | border: solid 3px transparent; 3 | transition: 0.2s; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $backgroundColor: white; 2 | $buttonColor: #0353a4; 3 | $scrollbar-color: #4a4a4a; 4 | -------------------------------------------------------------------------------- /client/src/components/Center.js: -------------------------------------------------------------------------------- 1 | import './styles/Center.css'; 2 | 3 | const Center = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default Center; 8 | -------------------------------------------------------------------------------- /client/src/stores/pageStore.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | export const usePageStore = create((set) => ({ 4 | page: '', 5 | 6 | setPage: (page) => set(() => ({ page: page })), 7 | })); 8 | -------------------------------------------------------------------------------- /client/src/components/styles/Center.scss: -------------------------------------------------------------------------------- 1 | #center { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | flex-direction: column; 6 | height: 80vh; 7 | margin-top: 20px; 8 | padding-bottom: 90px; 9 | } 10 | -------------------------------------------------------------------------------- /routes/userRoute.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { v4: uuid } = require('uuid'); 3 | 4 | // GET /api/user/ 5 | router.get('/', (req, res) => { 6 | res.send(`user-${uuid()}`); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /client/src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import './styles/Loader.css'; 2 | 3 | const Loader = ({ children }) => { 4 | return ( 5 | <> 6 |
7 | {children ? { ...children } :

Loading

} 8 | 9 | ); 10 | }; 11 | 12 | export default Loader; 13 | -------------------------------------------------------------------------------- /client/src/pages/styles/Folder.scss: -------------------------------------------------------------------------------- 1 | table { 2 | padding: 0 10px; 3 | width: 50vw; 4 | border: dashed 1px black; 5 | border-radius: 5px; 6 | 7 | td { 8 | padding: 5px; 9 | } 10 | } 11 | 12 | @media only screen and (max-width: 599px) { 13 | table { 14 | width: 90vw; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/styles/UserDisplay.scss: -------------------------------------------------------------------------------- 1 | .user-display { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | text-align: center; 7 | transition: 0.2s; 8 | 9 | &:hover { 10 | cursor: pointer; 11 | transform: scale(1.2); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/pages/Home.js: -------------------------------------------------------------------------------- 1 | import Center from '../components/Center'; 2 | import Navbar from '../components/Navbar'; 3 | 4 | const Home = () => { 5 | return ( 6 | <> 7 | 8 |
9 |

Nothing Here

10 |
11 | 12 | ); 13 | }; 14 | 15 | export default Home; 16 | -------------------------------------------------------------------------------- /client/src/components/styles/Camera.scss: -------------------------------------------------------------------------------- 1 | #camera-container { 2 | margin: 0; 3 | padding: 0; 4 | max-width: 90vw; 5 | max-height: 90vh; 6 | border: solid 3px red; 7 | border-radius: 10px; 8 | overflow: hidden; 9 | } 10 | 11 | #camera-video { 12 | width: 100%; 13 | height: 100%; 14 | object-fit: cover; 15 | transform: scale(5); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/styles/Navbar.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | #navbar { 4 | display: flex; 5 | position: fixed; 6 | bottom: 0; 7 | width: 100vw; 8 | align-items: center; 9 | justify-content: center; 10 | text-align: center; 11 | height: 70px; 12 | background-color: $backgroundColor; 13 | border: 1px black; 14 | border-style: dashed none none none; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/components/Button.js: -------------------------------------------------------------------------------- 1 | import { default as MuiButton } from '@mui/material/Button'; 2 | 3 | const Button = ({ children, onClick, margin = '15px', endIcon }) => { 4 | return ( 5 | 12 | {children} 13 | 14 | ); 15 | }; 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /client/src/stores/userStore.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | export const useUserStore = create((set) => ({ 4 | userAgent: '', 5 | userId: '', 6 | networkAddress: '', 7 | users: [], 8 | 9 | setUserAgent: (userAgent) => set(() => ({ userAgent: userAgent })), 10 | setUserId: (userId) => set(() => ({ userId: userId })), 11 | setNetworkAddress: (networkAddress) => 12 | set(() => ({ networkAddress: networkAddress })), 13 | setUsers: (users) => set(() => ({ users: users })), 14 | })); 15 | -------------------------------------------------------------------------------- /client/src/utils/config.js: -------------------------------------------------------------------------------- 1 | // Default to dev environment 2 | export let QR_URL_ORIGIN = window.location.origin.replace( 3 | 'localhost', 4 | '192.168.1.12' 5 | ); 6 | export let PORT = '5000'; 7 | let wsProtocol = 'ws'; 8 | 9 | if (process.env.NODE_ENV === 'production') { 10 | QR_URL_ORIGIN = window.location.origin; 11 | PORT = ''; 12 | wsProtocol = 'wss'; 13 | } 14 | 15 | export const SERVER_DOMAIN = window.location.hostname; 16 | export const API_SERVER = window.location.origin; 17 | export const WEB_SOCKET_SERVER = `${wsProtocol}://${SERVER_DOMAIN}:${PORT}`; 18 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Sementara", 3 | "name": "Sementara", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Sementara 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/services/WebSocketService.js: -------------------------------------------------------------------------------- 1 | import { WEB_SOCKET_SERVER } from '../utils/config'; 2 | 3 | class WebSocketService { 4 | constructor() { 5 | // Singleton not exists 6 | if (!WebSocketService.instance) { 7 | this.ws = null; 8 | WebSocketService.instance = this; 9 | } 10 | 11 | return WebSocketService.instance; 12 | } 13 | 14 | getWebSocket() { 15 | if (!this.ws || this.ws.readyState !== this.ws.OPEN) { 16 | this.ws = new WebSocket(WEB_SOCKET_SERVER); 17 | } 18 | 19 | return this.ws; 20 | } 21 | 22 | close() { 23 | if (this.ws) { 24 | this.ws.close(); 25 | this.ws = null; 26 | } 27 | } 28 | } 29 | 30 | export default new WebSocketService(); 31 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | 15 | // registerServiceWorker(); 16 | async function registerServiceWorker() { 17 | if (navigator.serviceWorker) { 18 | try { 19 | const registration = await navigator.serviceWorker.register('/sw.js'); 20 | console.log('Service Worker registered'); 21 | } catch (err) { 22 | console.log(`Error: ${err}`); 23 | } 24 | } else { 25 | console.log('Service worker not supported by your browser'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/styles/App.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | 3 | body { 4 | margin: 0; 5 | color: black; 6 | background-color: $backgroundColor; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | 13 | &:hover::-webkit-scrollbar-thumb { 14 | background: $scrollbar-color; 15 | } 16 | } 17 | 18 | code { 19 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 20 | monospace; 21 | } 22 | 23 | ::-webkit-scrollbar { 24 | width: 10px; 25 | } 26 | 27 | ::-webkit-scrollbar-thumb { 28 | background: transparent; 29 | 30 | &:hover { 31 | background: $scrollbar-color; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /routes/socketRoute.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { v4: uuid } = require('uuid'); 3 | const { getNetworkAddress } = require('../utils/network'); 4 | 5 | // GET /api/socket/connect 6 | router.get('/connect', (req, res) => { 7 | const userAgent = req.headers['user-agent']; 8 | const userId = uuid(); 9 | const networkAddress = getNetworkAddress(req); 10 | 11 | const params = { 12 | action: 'connect', 13 | userAgent, 14 | userId, 15 | networkAddress, 16 | }; 17 | 18 | res.json(params); 19 | }); 20 | 21 | // GET /api/socket/join 22 | router.get('/join', (req, res) => { 23 | const userId = uuid(); 24 | const networkAddress = getNetworkAddress(req); 25 | 26 | const params = { 27 | action: 'join', 28 | userId, 29 | networkAddress, 30 | }; 31 | 32 | res.json(params); 33 | }); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /client/src/components/Qr.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import QRCode from 'qrcode'; 3 | 4 | const Qr = ({ qrData }) => { 5 | const [qrSrc, setQrSrc] = useState(''); 6 | 7 | const qrRef = useRef(null); 8 | 9 | useEffect(() => { 10 | (async () => { 11 | const qrUrl = await QRCode.toDataURL(qrData, { 12 | errorCorrectionLevel: 'high', 13 | }); 14 | setQrSrc(qrUrl); 15 | })(); 16 | }, []); 17 | 18 | useEffect(() => { 19 | if (!qrSrc) { 20 | return; 21 | } 22 | 23 | qrRef.current.hidden = false; 24 | }, [qrSrc]); 25 | 26 | return ( 27 | <> 28 | 35 | 36 | ); 37 | }; 38 | 39 | export default Qr; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sementara", 3 | "version": "1.2.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server", 8 | "server": "nodemon server", 9 | "client": "npm start --prefix client", 10 | "dev": "concurrently \"npm run server\" \"npm run client\"", 11 | "install-client": "npm install --prefix client", 12 | "build-client": "npm run build --prefix client" 13 | }, 14 | "keywords": [], 15 | "author": "Adib Zaini", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@google-cloud/storage": "^7.13.0", 19 | "compression": "^1.7.4", 20 | "express": "^4.21.0", 21 | "ipaddr.js": "^2.2.0", 22 | "multer": "^1.4.5-lts.1", 23 | "peer": "^1.0.2", 24 | "uuid": "^10.0.0", 25 | "ws": "^8.18.0" 26 | }, 27 | "devDependencies": { 28 | "concurrently": "^9.0.1", 29 | "nodemon": "^3.1.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Express Logo](https://sementara.skrin.xyz/favicon.ico)](http://sementara.skrin.xyz/send) 2 | 3 | # Sementara 4 | 5 | Web app for sharing files powered by NodeJS & ReactJS 6 | 7 | ## Features 8 | 9 | ☑️ **File & folder sharing** 10 | 11 | ☑️ **Files & folder automatically zipped before upload** 12 | 13 | ☑️ **Share url using QR Code** 14 | 15 | ☑️ **Only one device required camera** 16 | 17 | ## Live Demo 18 | 19 | https://sementara.skrin.xyz/send 20 | 21 | ## Run Locally 22 | 23 | ### Clone This Repo 24 | 25 | ``` 26 | $ git clone https://github.com/ADIBzTER/sementara.git 27 | ``` 28 | 29 | ### Development Mode 30 | 31 | ``` 32 | $ npm install 33 | $ npm run install-client 34 | $ npm run dev 35 | ``` 36 | ### Production Mode 37 | 38 | ``` 39 | $ npm install 40 | $ npm run install-client 41 | $ npm run build-client 42 | $ npm start 43 | ``` 44 | 45 | ## License 46 | 47 | This project is licensed under the [MIT License](https://github.com/ADIBzTER/sementara/blob/master/LICENSE) 48 | -------------------------------------------------------------------------------- /routes/sendRoute.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const multer = require('multer'); 3 | const { v4: uuid } = require('uuid'); 4 | 5 | const { uploadBuffer } = require('../utils/googleStorage'); 6 | 7 | const storage = multer.memoryStorage(); 8 | const upload = multer({ storage: storage }); 9 | 10 | // POST /api/send/ 11 | router.post('/', upload.array('files'), async (req, res, next) => { 12 | const id = uuid(); 13 | 14 | try { 15 | await uploadToBucket(id, req.files); 16 | 17 | const params = { 18 | action: 'send', 19 | id, 20 | }; 21 | 22 | res.json(params); 23 | } catch (err) { 24 | console.error(err.message); 25 | res.status(500).json({}); 26 | } 27 | 28 | next(); 29 | }); 30 | 31 | async function uploadToBucket(id, files) { 32 | const promises = []; 33 | for (let file of files) { 34 | promises.push(uploadBuffer(id, file.originalname, file.buffer)); 35 | } 36 | 37 | await Promise.all(promises); 38 | } 39 | 40 | module.exports = router; 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official lightweight Node.js 16-alpine image. 2 | # https://hub.docker.com/_/node 3 | FROM node:20-alpine 4 | 5 | # Create and change to the app directory. 6 | WORKDIR . 7 | 8 | # Copy application dependency manifests to the container image. 9 | # A wildcard is used to ensure copying both package.json AND package-lock.json (when available). 10 | # Copying this first prevents re-running npm install on every code change. 11 | COPY package*.json ./ 12 | 13 | # Update npm 14 | RUN npm -g install npm@latest 15 | 16 | # Install production dependencies. 17 | # If you add a package-lock.json, speed your build by switching to 'npm ci'. 18 | # RUN npm ci --only=production 19 | RUN npm install --omit-dev 20 | 21 | # Copy local code to the container image. 22 | COPY . ./ 23 | 24 | # Setup client side 25 | WORKDIR ./client 26 | # RUN npm ci --only=production --silent && npm run build 27 | RUN npm install --only=production --silent && npm run build 28 | 29 | WORKDIR .. 30 | 31 | # Run the web service on container startup. 32 | CMD [ "npm", "start" ] 33 | -------------------------------------------------------------------------------- /routes/folderRoute.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const { getFile, getFolderInfo } = require('../utils/googleStorage'); 4 | 5 | // GET /api/folder/:id/info 6 | router.get('/:id/info', async (req, res) => { 7 | const id = req.params.id; 8 | 9 | try { 10 | const folderInfo = await getFolderInfo(id); 11 | res.json(folderInfo); 12 | } catch (err) { 13 | console.error(err.message); 14 | res.status(404).json({}); 15 | } 16 | }); 17 | 18 | // GET /api/folder/:id/download/one/:filename 19 | router.get('/:id/download/one/:filename', async (req, res) => { 20 | const id = req.params.id; 21 | const filename = req.params.filename; 22 | 23 | try { 24 | const file = await getFile(id, filename); 25 | const buffer = (await file.download())[0]; 26 | 27 | res.set('Content-Disposition', file.metadata.contentDisposition); 28 | res.set('Content-Type', file.metadata.contentType); 29 | res.send(buffer); 30 | } catch (err) { 31 | console.error(err.message); 32 | res.status(404).json({}); 33 | } 34 | }); 35 | 36 | module.exports = router; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Adib Zaini 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 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.13.3", 7 | "@emotion/styled": "^11.13.0", 8 | "@mui/icons-material": "^6.1.1", 9 | "@mui/material": "^6.1.1", 10 | "@testing-library/jest-dom": "^6.5.0", 11 | "fflate": "^0.8.2", 12 | "peerjs": "^1.5.4", 13 | "qr-scanner": "^1.4.2", 14 | "qrcode": "^1.5.4", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "react-router-dom": "^6.26.2", 18 | "react-scripts": "^5.0.1", 19 | "sass": "^1.79.4", 20 | "ua-parser-js": "^1.0.39", 21 | "web-vitals": "^4.2.3", 22 | "zustand": "^4.5.5" 23 | }, 24 | "scripts": { 25 | "postinstall": "sass src:src", 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": [ 33 | "react-app", 34 | "react-app/jest" 35 | ] 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "proxy": "http://localhost:5000" 50 | } 51 | -------------------------------------------------------------------------------- /utils/googleStorage.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { Storage } = require('@google-cloud/storage'); 4 | 5 | // Creates a client 6 | const storage = new Storage({ 7 | keyFilename: path.join(__dirname, '../secrets/skrin-sementara-bucket'), 8 | projectId: 'skrin-1', 9 | }); 10 | 11 | const bucketName = 'sementara'; 12 | const bucket = storage.bucket(bucketName); 13 | 14 | const getFile = async (id, filename) => { 15 | const destination = `temp/${id}/${filename}`; 16 | const [files] = await bucket.getFiles({ 17 | prefix: destination, 18 | }); 19 | 20 | return files[0]; 21 | }; 22 | 23 | const uploadBuffer = async (id, filename, buffer) => { 24 | const destination = `temp/${id}/${filename}`; 25 | await bucket.file(destination).save(buffer); 26 | 27 | console.log(`${filename} uploaded to ${bucketName} bucket`); 28 | }; 29 | 30 | const downloadBuffer = async (id, filename) => { 31 | const destination = `temp/${id}/${filename}`; 32 | const buffer = await bucket.file(destination).download(); 33 | 34 | return buffer[0]; 35 | }; 36 | 37 | const getFolderInfo = async (id) => { 38 | const buffer = await downloadBuffer(id, '.info'); 39 | const data = JSON.parse(buffer.toString()); 40 | 41 | for (let i in data.filenames) { 42 | data.filenames[i] = `temp/${id}/${data.filenames[i]}`; 43 | } 44 | 45 | return data; 46 | }; 47 | 48 | module.exports = { getFile, uploadBuffer, downloadBuffer, getFolderInfo }; 49 | -------------------------------------------------------------------------------- /client/public/sw.js: -------------------------------------------------------------------------------- 1 | // const CACHE_VERSION = 'v2'; 2 | 3 | // // Service worker installed 4 | // self.addEventListener('install', async (e) => { 5 | // console.log('Service Worker installed'); 6 | // }); 7 | 8 | // // Service worker activated 9 | // self.addEventListener('activate', async (e) => { 10 | // console.log('Service Worker activated'); 11 | 12 | // // Cleanup old caches 13 | // try { 14 | // const cacheNames = await caches.keys(); 15 | // for (let name of cacheNames) { 16 | // if (CACHE_VERSION !== name) { 17 | // caches.delete(name); 18 | // } 19 | // } 20 | // } catch (err) { 21 | // console.log(`Error: ${err}`); 22 | // } 23 | 24 | // // Fetch immediately 25 | // e.waitUntil(clients.claim()); 26 | // }); 27 | 28 | // // Listen for requests 29 | // self.addEventListener('fetch', (e) => { 30 | // console.log('Service Worker fetch'); 31 | 32 | // e.respondWith(handleRequest(e)); 33 | // }); 34 | 35 | // async function handleRequest(e) { 36 | // try { 37 | // // Respond 38 | // let response = await caches.match(e.request); 39 | // if (response) { 40 | // return response; 41 | // } else { 42 | // // Cache and respond 43 | // response = await fetch(e.request); 44 | // const cache = await caches.open(CACHE_VERSION); 45 | // cache.put(e.request, response.clone()); 46 | 47 | // return response; 48 | // } 49 | // } catch (err) { 50 | // return await caches.match(e.request); 51 | // } 52 | // } 53 | -------------------------------------------------------------------------------- /client/src/components/UserDisplay.js: -------------------------------------------------------------------------------- 1 | import { blue } from '@mui/material/colors'; 2 | import Avatar from '@mui/material/Avatar'; 3 | import VideogameAssetOutlinedIcon from '@mui/icons-material/VideogameAssetOutlined'; 4 | import SmartphoneOutlinedIcon from '@mui/icons-material/SmartphoneOutlined'; 5 | import TabletOutlinedIcon from '@mui/icons-material/TabletOutlined'; 6 | import TvOutlinedIcon from '@mui/icons-material/TvOutlined'; 7 | import WatchOutlinedIcon from '@mui/icons-material/WatchOutlined'; 8 | import MemoryOutlinedIcon from '@mui/icons-material/MemoryOutlined'; 9 | import DesktopWindowsOutlinedIcon from '@mui/icons-material/DesktopWindowsOutlined'; 10 | 11 | import './styles/UserDisplay.css'; 12 | 13 | const fontSize = 'large'; 14 | const userIcon = { 15 | console: VideogameAssetOutlinedIcon, 16 | mobile: SmartphoneOutlinedIcon, 17 | tablet: TabletOutlinedIcon, 18 | smartv: TvOutlinedIcon, 19 | wearable: WatchOutlinedIcon, 20 | embedded: MemoryOutlinedIcon, 21 | }; 22 | 23 | export default function UserDisplay({ displayName, deviceType, onClick }) { 24 | const UserIcon = userIcon[deviceType]; 25 | 26 | return ( 27 |
28 | 32 | {UserIcon ? ( 33 | 34 | ) : ( 35 | 36 | )} 37 | 38 | {displayName} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /utils/network.js: -------------------------------------------------------------------------------- 1 | const ipaddr = require('ipaddr.js'); 2 | 3 | /** 4 | * Get client's network address 5 | * 6 | * @param {IncomingMessage} req - Request from the client 7 | * @return {string} Network address 8 | * 9 | * @example 10 | * 11 | * getNetworkAddress(req) 12 | */ 13 | const getNetworkAddress = (req) => { 14 | const remoteAddress = 15 | req.headers['x-forwarded-for'] || req.socket.remoteAddress; 16 | 17 | if (!remoteAddress) { 18 | return; 19 | } 20 | 21 | let networkAddress = getFullIpAddress(req); 22 | const addr = ipaddr.parse(networkAddress); 23 | 24 | // Handle normal IPv6 25 | if (addr.kind() === 'ipv6') { 26 | networkAddress = networkAddress.split(':').slice(0, 4).join(':'); 27 | } 28 | 29 | return networkAddress; 30 | }; 31 | 32 | /** 33 | * Get client's full IP address 34 | * 35 | * @param {IncomingMessage} req - Request from the client 36 | * @return {string} Full IP address 37 | * 38 | * @example 39 | * 40 | * getFullIpAddress(remoteAddress) 41 | */ 42 | function getFullIpAddress(req) { 43 | const remoteAddress = 44 | req.headers['x-forwarded-for'] || req.socket.remoteAddress; 45 | 46 | if (!remoteAddress) { 47 | return; 48 | } 49 | 50 | let fullIpAddress = remoteAddress.split(',')[0]; 51 | 52 | if (ipaddr.isValid(fullIpAddress)) { 53 | const addr = ipaddr.parse(fullIpAddress); 54 | 55 | // Handle IPv4-mapped IPv6 56 | if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) { 57 | fullIpAddress = addr.toIPv4Address().toString(); 58 | } 59 | 60 | // Handle normal IPv6 61 | else if (addr.kind() === 'ipv6') { 62 | fullIpAddress = addr.toNormalizedString(); 63 | } 64 | } 65 | 66 | return fullIpAddress; 67 | } 68 | 69 | module.exports = { getNetworkAddress, getFullIpAddress }; 70 | -------------------------------------------------------------------------------- /client/src/components/Navbar.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import Box from '@mui/material/Box'; 5 | import CssBaseline from '@mui/material/CssBaseline'; 6 | import BottomNavigation from '@mui/material/BottomNavigation'; 7 | import BottomNavigationAction from '@mui/material/BottomNavigationAction'; 8 | import Home from '@mui/icons-material/Home'; 9 | import Upload from '@mui/icons-material/Upload'; 10 | import Download from '@mui/icons-material/Download'; 11 | import Paper from '@mui/material/Paper'; 12 | 13 | import { usePageStore } from '../stores/pageStore'; 14 | 15 | const pageMap = ['', 'send', 'receive']; 16 | 17 | export default function Navbar() { 18 | const ref = useRef(null); 19 | 20 | const [page, setPage] = usePageStore((state) => [state.page, state.setPage]); 21 | 22 | const navigate = useNavigate(); 23 | 24 | useEffect(() => { 25 | const initialPage = window.location.pathname.split('/')[1]; 26 | setPage(initialPage); 27 | }, []); 28 | 29 | return ( 30 | 31 | 32 | 36 | { 40 | setTimeout(() => { 41 | navigate(`/${pageMap[newValue]}`); 42 | }, 100); 43 | }} 44 | > 45 | } /> 46 | } /> 47 | } /> 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /client/src/components/styles/Loader.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables'; 2 | 3 | .loader { 4 | font-size: 10px; 5 | margin: 50px auto; 6 | text-indent: -9999em; 7 | width: 11em; 8 | height: 11em; 9 | border-radius: 50%; 10 | background: #ffffff; 11 | background: -moz-linear-gradient( 12 | left, 13 | #ffffff 10%, 14 | rgba(255, 255, 255, 0) 42% 15 | ); 16 | background: -webkit-linear-gradient( 17 | left, 18 | #ffffff 10%, 19 | rgba(255, 255, 255, 0) 42% 20 | ); 21 | background: -o-linear-gradient(left, #ffffff 10%, rgba(255, 255, 255, 0) 42%); 22 | background: -ms-linear-gradient( 23 | left, 24 | #ffffff 10%, 25 | rgba(255, 255, 255, 0) 42% 26 | ); 27 | background: linear-gradient( 28 | to right, 29 | #ffffff 10%, 30 | rgba(255, 255, 255, 0) 42% 31 | ); 32 | position: relative; 33 | -webkit-animation: load3 1.4s infinite linear; 34 | animation: load3 1.4s infinite linear; 35 | -webkit-transform: translateZ(0); 36 | -ms-transform: translateZ(0); 37 | transform: translateZ(0); 38 | } 39 | .loader:before { 40 | width: 50%; 41 | height: 50%; 42 | background: #ffffff; 43 | border-radius: 100% 0 0 0; 44 | position: absolute; 45 | top: 0; 46 | left: 0; 47 | content: ''; 48 | } 49 | .loader:after { 50 | background: $backgroundColor; 51 | width: 75%; 52 | height: 75%; 53 | border-radius: 50%; 54 | content: ''; 55 | margin: auto; 56 | position: absolute; 57 | top: 0; 58 | left: 0; 59 | bottom: 0; 60 | right: 0; 61 | } 62 | @-webkit-keyframes load3 { 63 | 0% { 64 | -webkit-transform: rotate(0deg); 65 | transform: rotate(0deg); 66 | } 67 | 100% { 68 | -webkit-transform: rotate(360deg); 69 | transform: rotate(360deg); 70 | } 71 | } 72 | @keyframes load3 { 73 | 0% { 74 | -webkit-transform: rotate(0deg); 75 | transform: rotate(0deg); 76 | } 77 | 100% { 78 | -webkit-transform: rotate(360deg); 79 | transform: rotate(360deg); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/pages/Receive.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | import CameraAltIcon from '@mui/icons-material/CameraAlt'; 4 | import QrCode2Icon from '@mui/icons-material/QrCode2'; 5 | 6 | import Qr from '../components/Qr'; 7 | import Camera from '../components/Camera'; 8 | 9 | import Navbar from '../components/Navbar'; 10 | import Center from '../components/Center'; 11 | import Button from '../components/Button'; 12 | import Loader from '../components/Loader'; 13 | 14 | import { QR_URL_ORIGIN } from '../utils/config'; 15 | import { useUserStore } from '../stores/userStore'; 16 | 17 | const Receive = () => { 18 | const [method, setMethod] = useState('qr'); 19 | const [methodButton, setMethodButton] = useState({ 20 | text: 'Camera', 21 | icon: , 22 | }); 23 | const [qr, setQr] = useState(null); 24 | const [camera, setCamera] = useState(null); 25 | 26 | const [userId] = useUserStore((state) => [state.userId, state.setUsers]); 27 | 28 | window.cameraStream = new MediaStream(); 29 | 30 | useEffect(() => { 31 | (async () => { 32 | if (!userId) { 33 | return; 34 | } 35 | 36 | await getQr(); 37 | })(); 38 | }, [userId]); 39 | 40 | async function getQr() { 41 | setQr(); 42 | setCamera(); 43 | } 44 | 45 | function handleMethod() { 46 | if (method === 'qr') { 47 | setMethod('camera'); 48 | setMethodButton({ text: 'QR', icon: }); 49 | } else if (method === 'camera') { 50 | setMethod('qr'); 51 | setMethodButton({ text: 'Camera', icon: }); 52 | } 53 | } 54 | 55 | return ( 56 | <> 57 | 58 |
59 | {!qr ? ( 60 | 61 | ) : ( 62 | <> 63 |

Receive Files

64 |
{method === 'qr' ? qr : camera}
65 |
66 | 69 |
70 | 71 | )} 72 |
73 | 74 | ); 75 | }; 76 | 77 | export default Receive; 78 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Routes, Route, useNavigate } from 'react-router-dom'; 3 | 4 | import Home from './pages/Home'; 5 | import Room from './pages/Room'; 6 | import Send from './pages/Send'; 7 | import Receive from './pages/Receive'; 8 | import Folder from './pages/Folder'; 9 | 10 | import './styles/App.css'; 11 | 12 | import { API_SERVER } from './utils/config'; 13 | import WebSocketService from './services/WebSocketService'; 14 | import { useUserStore } from './stores/userStore'; 15 | 16 | function App() { 17 | const [setUserAgent, setUserId, setNetworkAddress, setUsers] = useUserStore( 18 | (state) => [ 19 | state.setUserAgent, 20 | state.setUserId, 21 | state.setNetworkAddress, 22 | state.setUsers, 23 | ] 24 | ); 25 | 26 | const navigate = useNavigate(); 27 | 28 | useEffect(() => { 29 | (async () => { 30 | let res = await fetch(`${API_SERVER}/api/socket/connect`); 31 | res = await res.json(); 32 | 33 | setUserAgent(res.userAgent); 34 | setUserId(res.userId); 35 | setNetworkAddress(res.networkAddress); 36 | 37 | const ws = WebSocketService.getWebSocket(); 38 | ws.onopen = (e) => { 39 | ws.onmessage = (e) => { 40 | const data = JSON.parse(e.data); 41 | 42 | // Get users in same network 43 | if (data.action === 'network') { 44 | setUsers(data.users); 45 | } 46 | 47 | // Navigate receiver to folder 48 | else if (data.action === 'receive') { 49 | navigate(`/folder/${data.folderId}`); 50 | 51 | if (window.cameraStream) { 52 | window.cameraStream.getTracks().forEach((track) => { 53 | track.stop(); 54 | }); 55 | } 56 | } 57 | }; 58 | 59 | ws.send( 60 | JSON.stringify({ 61 | type: res.action, 62 | userAgent: res.userAgent, 63 | userId: res.userId, 64 | networkAddress: res.networkAddress, 65 | }) 66 | ); 67 | }; 68 | })(); 69 | }, []); 70 | 71 | return ( 72 | 73 | } /> 74 | } /> 75 | } /> 76 | } /> 77 | } /> 78 | } /> 79 | 80 | ); 81 | } 82 | 83 | export default App; 84 | -------------------------------------------------------------------------------- /client/src/pages/Room.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import Peer from 'peerjs'; 3 | 4 | import Qr from '../components/Qr'; 5 | import Camera from '../components/Camera'; 6 | 7 | import { PORT, SERVER_DOMAIN } from '../utils/config'; 8 | 9 | const Room = () => { 10 | const [peer, setPeer] = useState(null); 11 | const [callerConnection, setCallerConnection] = useState(null); 12 | const [peerId, setPeerId] = useState(''); 13 | const [roomId, setRoomId] = useState(''); 14 | const [message, setMessage] = useState(''); 15 | const [qr, setQr] = useState(null); 16 | const [camera, setCamera] = useState(null); 17 | 18 | const dialogRef = useRef(null); 19 | const formRef = useRef(null); 20 | const fileRef = useRef(null); 21 | 22 | useEffect(() => { 23 | let path = window.location.pathname.split('/'); 24 | path = path[path.length - 1]; 25 | setRoomId(path); 26 | 27 | const peer = new Peer(undefined, { 28 | host: SERVER_DOMAIN, 29 | port: PORT, 30 | path: '/api', 31 | }); 32 | setPeer(peer); 33 | }, []); 34 | 35 | useEffect(() => { 36 | if (!peer) { 37 | return; 38 | } 39 | 40 | // Local peer 41 | peer.on('open', (id) => { 42 | setPeerId(id); 43 | setQr(); 44 | setCamera( 45 | 46 | ); 47 | }); 48 | 49 | peer.on('connection', (calleeConnection) => { 50 | calleeConnection.on('open', () => { 51 | console.log('Connection opened'); 52 | 53 | calleeConnection.on('data', (data) => { 54 | setMessage(data); 55 | console.log(data); 56 | }); 57 | }); 58 | }); 59 | 60 | peer.on('error', (err) => { 61 | console.error(`ERROR: ${err}`); 62 | }); 63 | }, [peer]); 64 | 65 | useEffect(() => { 66 | if (!callerConnection) { 67 | return; 68 | } 69 | 70 | callerConnection.on('open', () => { 71 | callerConnection.send(JSON.stringify({ roomId: peerId })); 72 | 73 | formRef.current.onsubmit = async (e) => { 74 | e.preventDefault(); 75 | 76 | showDialog('Sending Files...'); 77 | const data = await sendForm(); 78 | 79 | showDialog('File Sent'); 80 | setTimeout(() => { 81 | closeDialog(); 82 | }, 3000); 83 | }; 84 | }); 85 | }, [callerConnection]); 86 | 87 | function handleFileChange(e) { 88 | const files = e.target.files; 89 | checkFilesSizes(files); 90 | } 91 | 92 | function checkFilesSizes(files) { 93 | const limit = 20; // 20 MB 94 | let totalSize = 0; 95 | for (let file of files) { 96 | totalSize += file.size; 97 | } 98 | 99 | if (totalSize > limit * 1024 * 1024) { 100 | showDialog(`Total size of files must not exceed ${limit} MB`); 101 | setTimeout(() => { 102 | closeDialog(); 103 | }, 3000); 104 | } 105 | } 106 | 107 | function showDialog(message) { 108 | setMessage(message); 109 | dialogRef.current.show(); 110 | } 111 | 112 | function closeDialog() { 113 | dialogRef.current.close(); 114 | } 115 | 116 | async function sendForm() { 117 | const files = fileRef.current.files; 118 | 119 | // const data = new FormData(); 120 | for (let file of files) { 121 | // data.append('files', file); 122 | const blobUrl = URL.createObjectURL(file); 123 | } 124 | } 125 | return ( 126 | <> 127 |
My ID: {peerId}
128 |
Room: {roomId}
129 | {message} 130 | 131 |
132 |
Select files
133 | 140 | {/*
Select Folder
141 | */} 149 |
150 |
151 |
152 | 153 |
154 | {qr} 155 | {camera} 156 | 157 | ); 158 | }; 159 | 160 | export default Room; 161 | -------------------------------------------------------------------------------- /client/src/components/Camera.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import QrScanner from 'qr-scanner'; 5 | 6 | import { WEB_SOCKET_SERVER } from '../utils/config'; 7 | import WebSocketService from '../services/WebSocketService'; 8 | 9 | import './styles/Camera.css'; 10 | 11 | const Camera = ({ folderId, sdp, peer, setCallerConnection }) => { 12 | const navigate = useNavigate(); 13 | window.cameraStream = new MediaStream(); 14 | 15 | const videoRef = useRef(null); 16 | 17 | useEffect(() => { 18 | (async () => { 19 | try { 20 | const stream = await navigator.mediaDevices.getUserMedia({ 21 | audio: false, 22 | video: { 23 | width: { ideal: 999999 }, 24 | height: { ideal: 999999 }, 25 | facingMode: { ideal: 'environment' }, 26 | }, 27 | }); 28 | window.cameraStream = stream; 29 | showCameraVideo(); 30 | 31 | const qrScanner = new QrScanner(videoRef.current, (data) => { 32 | const isValid = handleData(data); 33 | 34 | if (isValid) { 35 | qrScanner.destroy(); 36 | // alert('Done scanning'); 37 | } else { 38 | alert('Invalid QR Code'); 39 | } 40 | }); 41 | qrScanner.start(); 42 | } catch (err) { 43 | console.error(err); 44 | } 45 | })(); 46 | 47 | return () => { 48 | const tracks = window.cameraStream.getTracks(); 49 | for (let track of tracks) { 50 | window.cameraStream.removeTrack(track); 51 | track.stop(); 52 | } 53 | }; 54 | }, []); 55 | 56 | function showCameraVideo() { 57 | const video = videoRef.current; 58 | video.srcObject = window.cameraStream; 59 | 60 | video.onloadedmetadata = () => { 61 | video.play(); 62 | }; 63 | } 64 | 65 | function handleData(data) { 66 | let whitelistedDomain = [ 67 | 'sementara.skrin.xyz', 68 | 'localhost', 69 | '192.168.1.12', 70 | 'sementara-dev-c3d6yhsnla-as.a.run.app', 71 | ]; 72 | 73 | // https://localhost:3000/:action/:UUID 74 | let url = new URL(data); 75 | if (!whitelistedDomain.includes(url.hostname)) { 76 | return false; 77 | } 78 | 79 | let action, uuid; 80 | try { 81 | data = data.split('/'); 82 | action = data[3]; 83 | uuid = data[4]; 84 | } catch (err) { 85 | return false; 86 | } 87 | 88 | // Check QR validity 89 | if (!action || !uuid) { 90 | return false; 91 | } 92 | 93 | // QR created by sender 94 | if (action === 'folder') { 95 | if (folderId) { 96 | return false; 97 | } 98 | navigate(url.pathname); 99 | } 100 | 101 | // QR created by receiver 102 | else if (action === 'receive') { 103 | if (!folderId) { 104 | return false; 105 | } 106 | 107 | const ws = WebSocketService.getWebSocket(); 108 | ws.send( 109 | JSON.stringify({ 110 | type: 'message', 111 | action, 112 | userId: uuid, 113 | folderId, 114 | }) 115 | ); 116 | } 117 | return true; 118 | } 119 | 120 | function _handleData(data) { 121 | data = JSON.parse(data); 122 | const action = data.action; 123 | const userId = data.userId; 124 | folderId = folderId || data.folderId; 125 | 126 | // QR created for webRTC connection 127 | if (action === 'connect') { 128 | const conn = peer.connect(data.peerId); 129 | window.conn = conn; 130 | setCallerConnection(conn); 131 | const ws = new WebSocket(WEB_SOCKET_SERVER); 132 | ws.onopen = (e) => { 133 | ws.send( 134 | JSON.stringify({ 135 | type: 'message', 136 | action, 137 | // id, 138 | sdp, 139 | }) 140 | ); 141 | ws.close(); 142 | }; 143 | } 144 | 145 | // QR for joining room 146 | else if (action === 'join') { 147 | const ws = window.ws; 148 | ws.send( 149 | JSON.stringify({ 150 | type: 'join', 151 | action, 152 | roomId: data.roomId, 153 | }) 154 | ); 155 | navigate(`/room/${data.roomId}`, { state: { caller: true } }); 156 | } 157 | 158 | return true; 159 | } 160 | 161 | return ( 162 | <> 163 |
164 | 165 |
166 | 167 | ); 168 | }; 169 | 170 | export default Camera; 171 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const express = require('express'); 4 | const compression = require('compression'); 5 | const WebSocket = require('ws'); 6 | // const { ExpressPeerServer } = require('peer'); 7 | 8 | const { getFullIpAddress, getNetworkAddress } = require('./utils/network'); 9 | 10 | const app = express(); 11 | app.use(compression({ memLevel: 9 })); 12 | 13 | const PORT = process.env.PORT || 5000; 14 | const httpServer = app.listen(PORT, () => { 15 | console.log('Server start on port:', PORT); 16 | }); 17 | 18 | app.use((req, res, next) => { 19 | res.header('Access-Control-Allow-Origin', '*'); 20 | next(); 21 | }); 22 | 23 | // Body Parser Middleware 24 | app.use(express.json()); 25 | app.use(express.urlencoded({ extended: false })); 26 | 27 | // API endpoint 28 | app.use('/api/socket', require('./routes/socketRoute')); 29 | app.use('/api/send', require('./routes/sendRoute')); 30 | app.use('/api/folder', require('./routes/folderRoute')); 31 | 32 | // // PeerJS path 33 | // const peerServer = ExpressPeerServer(httpServer); 34 | 35 | // app.use('/api', peerServer); 36 | 37 | // Server static files 38 | app.use(express.static('client/build')); 39 | 40 | // Handle URL 41 | app.get('*', (req, res) => { 42 | res.sendFile(path.resolve(__dirname, 'client/build/index.html')); 43 | }); 44 | 45 | // WebSocket Server 46 | const wss = new WebSocket.Server({ server: httpServer }); 47 | wss.setMaxListeners(0); 48 | 49 | const clients = {}; 50 | wss.on('connection', (ws, req) => { 51 | console.log(`Full IP address: ${getFullIpAddress(req)}`); 52 | console.log(`Network address: ${getNetworkAddress(req)}`); 53 | 54 | // Recieve message from client 55 | ws.on('message', (message) => { 56 | const data = JSON.parse(message.toString()); 57 | 58 | // Should happen once in the app startup 59 | if (data.type === 'connect') { 60 | ws.userAgent = data.userAgent; 61 | ws.userId = data.userId; 62 | ws.networkAddress = data.networkAddress; 63 | clients[ws.userId] = ws; 64 | 65 | sendUsersInSameNetwork(wss, ws); 66 | } 67 | 68 | // Get list of users in same network 69 | else if (data.type === 'network') { 70 | sendUsersInSameNetwork(wss, ws); 71 | } 72 | 73 | // Client in room 74 | else if (data.type === 'room') { 75 | ws.userId = data.userId; 76 | clients[ws.userId] = ws; 77 | } 78 | 79 | // Client disconnect 80 | else if (data.type === 'disconnect') { 81 | delete clients[data.userId]; 82 | } 83 | 84 | // Handle message from client 85 | else if (data.type === 'message') { 86 | // Send message to room except the sender 87 | // sendToRoom(wss, ws, data); 88 | 89 | // Send message to specific client 90 | sendToClient(data.userId, data); 91 | } 92 | }); 93 | 94 | ws.on('close', () => { 95 | delete clients[ws.userId]; 96 | sendUsersInSameNetwork(wss, ws); 97 | }); 98 | 99 | ws.on('error', (err) => { 100 | console.error(`ERROR: ${err}`); 101 | }); 102 | }); 103 | 104 | function sendToClient(clientId, data) { 105 | const client = clients[clientId]; 106 | if (client.readyState === WebSocket.OPEN) { 107 | client.send(JSON.stringify(data)); 108 | } 109 | } 110 | 111 | function sendToRoom(wss, ws, data) { 112 | wss.clients.forEach((client) => { 113 | if ( 114 | client !== ws && 115 | client.readyState === WebSocket.OPEN && 116 | client.roomId === data.roomId 117 | ) { 118 | client.send(JSON.stringify(data)); 119 | } 120 | }); 121 | } 122 | 123 | function sendUsersInSameNetwork(wss, ws) { 124 | const params = { 125 | action: 'network', 126 | networkAddress: ws.networkAddress, 127 | users: [], 128 | }; 129 | 130 | for (const [key, value] of Object.entries(clients)) { 131 | if (value.networkAddress === ws.networkAddress) { 132 | const userDetail = { 133 | userAgent: value.userAgent, 134 | userId: value.userId, 135 | }; 136 | params.users.push(userDetail); 137 | } 138 | } 139 | 140 | wss.clients.forEach((client) => { 141 | if ( 142 | client.readyState === WebSocket.OPEN && 143 | client.networkAddress === ws.networkAddress 144 | ) { 145 | client.send(JSON.stringify(params)); 146 | } 147 | }); 148 | } 149 | 150 | // // PeerJS Server 151 | // peerServer.connect() 152 | // peerServer.on('connection', (client) => { 153 | // console.log(`Client connected: ${client.getId()}`); 154 | // }); 155 | 156 | // peerServer.on('error', (err) => { 157 | // console.error(`ERROR: ${err}`); 158 | // }); 159 | -------------------------------------------------------------------------------- /client/src/pages/Folder.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import userAgentParser from 'ua-parser-js'; 5 | 6 | import { Box } from '@mui/material'; 7 | import CameraAltIcon from '@mui/icons-material/CameraAlt'; 8 | import QrCode2Icon from '@mui/icons-material/QrCode2'; 9 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; 10 | import DownloadIcon from '@mui/icons-material/Download'; 11 | 12 | import Qr from '../components/Qr'; 13 | import Camera from '../components/Camera'; 14 | 15 | import Navbar from '../components/Navbar'; 16 | import Center from '../components/Center'; 17 | import Button from '../components/Button'; 18 | import Loader from '../components/Loader'; 19 | import UserDisplay from '../components/UserDisplay'; 20 | 21 | import { API_SERVER, QR_URL_ORIGIN } from '../utils/config'; 22 | import { useUserStore } from '../stores/userStore'; 23 | import WebSocketService from '../services/WebSocketService'; 24 | 25 | import './styles/Folder.css'; 26 | 27 | const Folder = () => { 28 | const [type, setType] = useState(''); 29 | const [filenames, setFilenames] = useState([]); 30 | const [folderId, setFolderId] = useState(''); 31 | const [isDownloading, setIsDownloading] = useState(false); 32 | const [progress, setProgress] = useState(0); 33 | const [method, setMethod] = useState('qr'); 34 | const [methodButton, setMethodButton] = useState({ 35 | text: 'Camera', 36 | icon: , 37 | }); 38 | const [qr, setQr] = useState(null); 39 | const [camera, setCamera] = useState(null); 40 | 41 | const [userAgent, userId, users] = useUserStore((state) => [ 42 | state.userAgent, 43 | state.userId, 44 | state.users, 45 | ]); 46 | 47 | const navigate = useNavigate(); 48 | 49 | useEffect(() => { 50 | // /folder/:id/info 51 | const id = window.location.pathname.split('/')[2]; 52 | setFolderId(id); 53 | }, []); 54 | 55 | useEffect(() => { 56 | if (!folderId || !userId) { 57 | return; 58 | } 59 | 60 | (async () => { 61 | try { 62 | const res = await fetch(`${API_SERVER}/api/folder/${folderId}/info`); 63 | 64 | // Folder not found 65 | if (res.status !== 200) { 66 | navigate('/', { replace: true }); 67 | return; 68 | } 69 | 70 | setQr(); 71 | setCamera(); 72 | 73 | const { type, filenames } = await res.json(); 74 | 75 | for (let i in filenames) { 76 | let temp = filenames[i].split('/'); 77 | temp = temp.slice(2); 78 | filenames[i] = temp.join('/'); 79 | } 80 | 81 | setType(type); 82 | setFilenames(filenames); 83 | } catch (err) { 84 | console.error(err.message); 85 | } 86 | })(); 87 | }, [folderId, userId]); 88 | 89 | function handleMethod() { 90 | if (method === 'qr') { 91 | setMethod('camera'); 92 | setMethodButton({ text: 'QR', icon: }); 93 | } else if (method === 'camera') { 94 | setMethod('qr'); 95 | setMethodButton({ text: 'Camera', icon: }); 96 | } 97 | } 98 | 99 | function handleCopyUrl(e) { 100 | navigator.clipboard.writeText(window.location.href); 101 | e.target.innerText = 'URL Copied ✔'; 102 | e.target.style.backgroundColor = 'green'; 103 | } 104 | 105 | function handleDownload() { 106 | setIsDownloading(true); 107 | if (type === 'folder' || filenames.length === 1) { 108 | downloadZipFile(filenames[0]); 109 | } else if (type === 'file') { 110 | downloadZipFile('sementara.zip'); 111 | } 112 | } 113 | 114 | async function downloadZipFile(filename) { 115 | const xhr = new XMLHttpRequest(); 116 | xhr.responseType = 'blob'; 117 | 118 | xhr.onprogress = (e) => { 119 | if (e.lengthComputable) { 120 | setProgress((e.loaded / e.total) * 100); 121 | } 122 | }; 123 | 124 | xhr.onload = () => { 125 | downloadToDisk(xhr.response, filename); 126 | setIsDownloading(false); 127 | }; 128 | 129 | xhr.open( 130 | 'GET', 131 | `${API_SERVER}/api/folder/${folderId}/download/one/${filename}` 132 | ); 133 | 134 | xhr.send(); 135 | } 136 | 137 | function downloadToDisk(blob, filename) { 138 | const url = URL.createObjectURL(blob); 139 | const a = document.createElement('a'); 140 | a.href = url; 141 | a.download = filename; 142 | 143 | document.body.appendChild(a); 144 | a.click(); 145 | a.remove(); 146 | } 147 | 148 | function handleSendWsMessage(userId) { 149 | const ws = WebSocketService.getWebSocket(); 150 | ws.send( 151 | JSON.stringify({ 152 | type: 'message', 153 | action: 'receive', 154 | userId, 155 | folderId, 156 | }) 157 | ); 158 | } 159 | 160 | return ( 161 | <> 162 | 163 |
164 | {!filenames || !qr || isDownloading ? ( 165 | 166 | {isDownloading ? ( 167 | <> 168 |

Downloading Files

169 | 170 | {progress} % 171 | 172 | ) : undefined} 173 |
174 | ) : ( 175 | <> 176 |
{method === 'qr' ? qr : camera}
177 |
178 | 181 | 184 |
185 | 186 |
187 | 188 | 189 | {filenames.map((filename, i) => { 190 | return ( 191 | 192 | 195 | 196 | ); 197 | })} 198 | 199 |
193 |
  • {filename}
  • 194 |
    200 | 203 | 204 |

    Devices in your network:

    205 | 206 | {users.map((user, i) => { 207 | const { os, browser, device } = userAgentParser(user.userAgent); 208 | let displayName = `${os.name} ${browser.name}`; 209 | displayName = 210 | user.userId === userId ? displayName + ' (You)' : displayName; 211 | 212 | return ( 213 | 214 | handleSendWsMessage(user.userId)} 216 | displayName={displayName} 217 | deviceType={device.type} 218 | /> 219 | 220 | ); 221 | })} 222 | 223 | 224 | )} 225 |
    226 | 227 | ); 228 | }; 229 | 230 | export default Folder; 231 | -------------------------------------------------------------------------------- /client/src/pages/Send.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { zip } from 'fflate'; 4 | 5 | import FileOpenIcon from '@mui/icons-material/FileOpen'; 6 | import FolderOpenIcon from '@mui/icons-material/FolderOpen'; 7 | 8 | import Navbar from '../components/Navbar'; 9 | import Center from '../components/Center'; 10 | import Button from '../components/Button'; 11 | import Loader from '../components/Loader'; 12 | 13 | import { API_SERVER } from '../utils/config'; 14 | 15 | import './styles/Send.css'; 16 | 17 | const Send = () => { 18 | const [message, setMessage] = useState(''); 19 | const [isUploading, setIsUploading] = useState(false); 20 | const [progress, setProgress] = useState(0); 21 | 22 | const dialogRef = useRef(null); 23 | const fileRef = useRef(null); 24 | 25 | const navigate = useNavigate(); 26 | 27 | function handleUpload(type) { 28 | const input = fileRef.current; 29 | 30 | if (type === 'file') { 31 | input.removeAttribute('webkitdirectory'); 32 | } else if (type === 'folder') { 33 | input.setAttribute('webkitdirectory', ''); 34 | } 35 | 36 | input.click(); 37 | } 38 | 39 | function handleFileChange(e) { 40 | const files = e.target.files; 41 | if (checkFilesSizes(files)) { 42 | postForm(); 43 | } 44 | } 45 | 46 | function handleDragOverEvent(e) { 47 | if (e.target.id !== 'center') { 48 | return; 49 | } 50 | 51 | e.preventDefault(); 52 | e.target.style.background = 'gray'; 53 | e.target.style.border = 'dashed 3px white'; 54 | e.target.style.transition = '0.2s'; 55 | } 56 | 57 | function handleDragLeaveEvent(e) { 58 | if (e.target.id !== 'center') { 59 | return; 60 | } 61 | 62 | e.preventDefault(); 63 | e.target.style.background = ''; 64 | e.target.style.border = 'dashed 3px transparent'; 65 | } 66 | 67 | function handleDropEvent(e) { 68 | if (e.target.id !== 'center') { 69 | return; 70 | } 71 | e.preventDefault(); 72 | // const items = e.dataTransfer.items; 73 | // for (let item of items) { 74 | // item = item.webkitGetAsEntry(); 75 | // if (item) { 76 | // scanFiles(item); 77 | // } 78 | // } 79 | e.target.style.background = ''; 80 | e.target.style.border = 'dashed 3px transparent'; 81 | 82 | const input = fileRef.current; 83 | const files = e.dataTransfer.files; 84 | input.files = files; 85 | 86 | const string = `Are you sure want to upload ${files.length} files(s)?`; 87 | if (window.confirm(string)) { 88 | if (checkFilesSizes(files)) { 89 | postForm(); 90 | } 91 | } else { 92 | input.value = ''; 93 | } 94 | } 95 | 96 | // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry 97 | function scanFiles(item) { 98 | if (item.isFile) { 99 | item.file((file) => { 100 | const newFile = new File([file], file.name); 101 | file.webkitRelativePath = item.fullPath; 102 | console.log(file); 103 | }); 104 | } else if (item.isDirectory) { 105 | let directoryReader = item.createReader(); 106 | directoryReader.readEntries((entries) => { 107 | entries.forEach((entry) => { 108 | scanFiles(entry); 109 | }); 110 | }); 111 | } 112 | } 113 | 114 | function checkFilesSizes(files) { 115 | const limit = 20; // 20 MB 116 | let totalSize = 0; 117 | for (let file of files) { 118 | totalSize += file.size; 119 | } 120 | 121 | if (totalSize > limit * 1024 * 1024) { 122 | fileRef.current.value = ''; 123 | showDialog(`Total size of files must not exceed ${limit} MB`); 124 | setTimeout(() => { 125 | closeDialog(); 126 | }, 5000); 127 | 128 | return false; 129 | } 130 | 131 | return true; 132 | } 133 | 134 | function showDialog(message) { 135 | setMessage(message); 136 | dialogRef.current.show(); 137 | } 138 | 139 | function closeDialog() { 140 | dialogRef.current.close(); 141 | } 142 | 143 | async function postForm() { 144 | setMessage('Zipping files'); 145 | setIsUploading(true); 146 | 147 | let files = fileRef.current.files; 148 | const infoFile = createInfoFile(files); 149 | 150 | let fileToUpload; 151 | if (files.length === 1) { 152 | fileToUpload = files[0]; 153 | } else { 154 | fileToUpload = await createZipFile(files); 155 | } 156 | 157 | const data = new FormData(); 158 | data.append('files', infoFile); 159 | data.append('files', fileToUpload); 160 | 161 | setMessage('Uploading files'); 162 | 163 | const xhr = new XMLHttpRequest(); 164 | xhr.upload.onprogress = (e) => { 165 | if (e.lengthComputable) { 166 | setProgress((e.loaded / e.total) * 100); 167 | } 168 | }; 169 | xhr.onloadend = () => { 170 | const res = JSON.parse(xhr.responseText); 171 | 172 | navigate(`/folder/${res.id}`); 173 | }; 174 | xhr.open('POST', `${API_SERVER}/api/send`); 175 | xhr.send(data); 176 | } 177 | 178 | function createInfoFile(files) { 179 | const data = { type: '', filenames: [] }; 180 | const isFolder = files[0].webkitRelativePath ? true : false; 181 | if (isFolder) { 182 | data.type = 'folder'; 183 | const filename = files[0].webkitRelativePath.split('/')[0] + '.zip'; 184 | data.filenames.push(filename); 185 | } else { 186 | data.type = 'file'; 187 | for (let file of files) { 188 | data.filenames.push(file.name); 189 | } 190 | } 191 | 192 | const file = new File([JSON.stringify(data)], '.info'); 193 | 194 | return file; 195 | } 196 | 197 | async function createZipFile(files) { 198 | const data = {}; 199 | let archiveName = ''; 200 | for (let file of files) { 201 | const webkitPath = file.webkitRelativePath.split('/'); 202 | const filePath = webkitPath.slice(1).join('/') || file.name; 203 | archiveName = webkitPath[0] ? `${webkitPath[0]}.zip` : 'sementara.zip'; 204 | 205 | data[filePath] = new Uint8Array(await file.arrayBuffer()); 206 | } 207 | 208 | return new Promise((resolve, reject) => { 209 | zip(data, { level: 9, mem: 1 }, (err, data) => { 210 | if (err) { 211 | console.error(err); 212 | } 213 | const file = new File([data], archiveName, { 214 | type: 'application/zip', 215 | }); 216 | resolve(file); 217 | }); 218 | }); 219 | } 220 | 221 | return ( 222 | <> 223 | 224 |
    230 |
    231 | {isUploading ? ( 232 | 233 | <> 234 |

    {message}

    235 | 236 | {progress} % 237 | 238 |
    239 | ) : ( 240 | <> 241 |

    Send

    242 | {message} 243 |
    244 | 250 | 256 |
    257 | 258 |

    or

    259 |

    Drop files here

    260 | 270 | 271 | )} 272 |
    273 |
    274 | 275 | ); 276 | }; 277 | 278 | export default Send; 279 | --------------------------------------------------------------------------------