diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af5b3f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +FROM node:20-alpine as build-stage + +WORKDIR /usr/src/app + +# Copy TypeScript config +COPY tsconfig.json ./ + +# Copy package.json and package-lock.json (if available) to the working directory +COPY package*.json ./ + +# Install all dependencies and build your app +RUN npm install +COPY src src +RUN npm run build + +# Production stage +FROM node:20-alpine as production-stage + +WORKDIR /usr/src/app + +# Copy package.json and package-lock.json (if available) for production dependencies +COPY package*.json ./ + +# Install only production dependencies +RUN npm install --only=production + +# Copy static files +COPY ./static ./static + +# Copy built assets from the build stage +COPY --from=build-stage /usr/src/app/dist ./dist + +# Your application binds to port 3000, make sure the container exposes this port +EXPOSE 3000 + +# The command to run your application +CMD [ "node", "dist/index.js" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cda8001 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5487594 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# SMS Gateway for Androidâ„¢ Web Client + +## Description + +This is a demonstration app that utilizes the [SMS Gateway for Androidâ„¢](https://sms.capcom.me/) to create an SMS management web interface. It leverages webhooks to display received SMS messages in real-time and provides the capability to send SMS messages. + +## Features + +- Connect to any account registered on the Cloud/Private server. +- Real-time receipt of SMS messages. +- Capability to send SMS messages. + +## Privacy + +The application does not store credentials anywhere, except in the session memory, which is cleared upon logout. Received and sent SMS messages are not stored at all for now. + +## Getting Started + +### Prerequisites + +- Node.js +- npm or yarn + +### Installation + +1. Clone the repository: + ```bash + git clone + ``` +2. Navigate into the project directory: + ```bash + cd web-client-ts + ``` +3. Install dependencies: + ```bash + npm install + ``` + or if you use yarn: + ```bash + yarn install + ``` + +### Configuration + +To configure the application, create a `.env` file in the project root directory. This file should contain your environment variables, which include: + +- `HTTP__PORT` or `PORT`: Specifies the port on which the server listens. +- `HTTP__SESSION_SECRET`: A secret key for session encryption. +- `GATEWAY__URL`: The URL of the gateway API. +- `GATEWAY__WEBHOOK_URL`: The external address of the server, appended with `/api/webhooks`, to receive webhooks. +- `NODE_ENV`: Set to `development` to enable development mode. + +For more detailed information on the configuration options, please refer to `src/config.ts`. + +### Running the Project + +- To run in development mode: + ```bash + npm run dev + ``` + or + ```bash + yarn dev + ``` + +- To build the project: + ```bash + npm run build + ``` + or + ```bash + yarn build + ``` + +- To start the server: + ```bash + npm start + ``` + or + ```bash + yarn start + ``` + +## Usage + +After starting the server, navigate to `http://localhost:` to view the application. + +## Technical details + +This app uses socket.io for real-time communication. + +### Events sequence + +Client always listens to events `login:success` and `login:fail`. The first one indicates that client successfully logged in by previously sending `login` event or saved in session credentials. The second one indicates that login attempt failed, current credentials can't be used anymore or session expired. + +Typical sequences: + +1. Client sends `login` event with credentials to login. +2. Server responds with `login:success` or `login:fail` event. +3. Client sends `sms:send` event with message to send new message. Server sends acknowledgement with success or failure. +4. Server sends `sms:received` event with newly received message. +5. Client sends `logout` event to destroy session. + +### Client emmited events + +- `login` with `{login: string, password: string}` payload - login attempt. Server responds by `login:success` or `login:fail` event. +- `sms:send` with `{phoneNumber: string, message: string}` payload - send SMS message. Server uses acknowledgement to respond with payload `{success: boolean, message?: string}`. +- `logout` - destroy session, server responds by `login:fail` event. + +### Server emmited events + +- `sms:received` with `{phoneNumber: string, message: string, receivedAt: Date}` payload - received SMS message. The client should add received message to the list. +- `login:success` - login successful. The client should move from login screen to messaging screen. +- `login:fail` - login failed. The client should move to login screen if it is not already there. + +## Contributing + +Contributions are welcome! Please feel free to submit a pull request. + +## License + +This project is licensed under the Apache-2.0 License - see the `LICENSE` file for details. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c545bbb..3430124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,18 @@ "": { "name": "web-client-ts", "version": "1.0.0", - "license": "ISC", + "license": "Apache-2.0", "dependencies": { "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "express-session": "^1.18.0", + "morgan": "^1.10.0", + "socket.io": "^4.7.5" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", + "@types/morgan": "^1.9.9", "@types/node": "^20.14.6", "nodemon": "^3.1.3", "ts-node": "^10.9.2", @@ -57,6 +62,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -100,6 +110,19 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -124,6 +147,15 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -136,11 +168,19 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/morgan": { + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", + "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.14.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.6.tgz", "integrity": "sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -244,6 +284,30 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -389,6 +453,18 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -469,6 +545,63 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -542,6 +675,29 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -859,6 +1015,32 @@ "node": "*" } }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -932,6 +1114,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -951,6 +1141,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1008,6 +1206,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1170,6 +1376,107 @@ "node": ">=10" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1287,6 +1594,17 @@ "node": ">=14.17" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1296,8 +1614,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unpipe": { "version": "1.0.0", @@ -1329,6 +1646,26 @@ "node": ">= 0.8" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 75d49d1..d2163c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "web-client-ts", "version": "1.0.0", - "description": "", + "description": "A real-time web application for managing SMS communications based on the SMS Gateway for Androidâ„¢.", "main": "dist/index.js", "scripts": { "build": "npx tsc", @@ -9,18 +9,34 @@ "dev": "nodemon src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "typescript", + "real-time", + "sms", + "communication", + "nodejs", + "messaging" + ], + "author": { + "name": "Aleksandr Soloshenko", + "email": "i@capcom.me", + "url": "https://github.com/capcom6" + }, + "license": "Apache-2.0", "dependencies": { "dotenv": "^16.4.5", - "express": "^4.19.2" + "express": "^4.19.2", + "express-session": "^1.18.0", + "morgan": "^1.10.0", + "socket.io": "^4.7.5" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", + "@types/morgan": "^1.9.9", "@types/node": "^20.14.6", "nodemon": "^3.1.3", "ts-node": "^10.9.2", "typescript": "^5.4.5" } -} +} \ No newline at end of file diff --git a/requests.http b/requests.http index 8b934bf..5ca28ab 100644 --- a/requests.http +++ b/requests.http @@ -1,4 +1,20 @@ @baseUrl=http://localhost:3000 ### -GET {{baseUrl}} HTTP/1.1 \ No newline at end of file +GET {{baseUrl}} HTTP/1.1 + +### +POST {{baseUrl}}/api/webhooks?sessionId=XA0RyZ8HlVEuPPe5jBpQJ0f_7dZPwaz6 HTTP/1.1 +Content-Type: application/json + +{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:received", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "message": "Android is always a sweet treat!", + "phoneNumber": "6505551212", + "receivedAt": "2024-06-22T15:46:11.000+07:00" + }, + "webhookId": "web-client-ts" +} \ No newline at end of file diff --git a/src/apis/gateway.ts b/src/apis/gateway.ts new file mode 100644 index 0000000..2959092 --- /dev/null +++ b/src/apis/gateway.ts @@ -0,0 +1,85 @@ +import { gateway } from "../config"; + +const BASE_URL = gateway.url; + +export class GatewayApi { + private authorization: string = ""; + + constructor( + login: string, + password: string, + private readonly url: string = BASE_URL + ) { + this.updateCredentials(login, password); + } + + updateCredentials(login: string, password: string) { + this.authorization = Buffer.from(`${login}:${password}`).toString("base64"); + } + + async webhookRegister(id: string, url: string) { + await this.fetch("webhooks", { + method: "POST", + body: JSON.stringify({ + id, + event: "sms:received", + url, + }), + }); + } + + async webhookUnregister(id: string) { + await this.fetch(`webhooks/${id}`, { + method: "DELETE", + }); + } + + async sendMessage(phoneNumber: string, message: string) { + await this.fetch("message", { + method: "POST", + body: JSON.stringify({ + phoneNumbers: [ + phoneNumber, + ], + message, + }), + }); + } + + private async fetch(path: string, init: RequestInit) { + const response = await fetch(`${this.url}/${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${this.authorization}`, + }, + }); + if (!response.ok) { + if (response.status === 401) { + throw new UnauthorizedError(); + } + + throw new Error(`API call to ${path} failed with ${response.status} ${response.statusText}: ${await response.text()}`); + } + return response; + } +} + +/////////////////////////////////////////////////////////////////////////////// +export class UnauthorizedError extends Error { } + +export type WebHookEventType = 'sms:received' | 'system:ping'; + +export type WebHookPayloadSmsReceived = { + message: string, + phoneNumber: string, + receivedAt: string, +}; + +export interface WebHookEvent { + id: string; + webhookId: string; + deviceId: string; + event: WebHookEventType; + payload: T; +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..387932d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,28 @@ +import { randomBytes } from "crypto"; +import dotenv from "dotenv"; + +dotenv.config(); + +export interface HttpConfig { + port: number; + sessionSecret: string; +} + +export interface GatewayConfig { + url: string; + webhookUrl: string; +} + +const httpPort = Number(process.env.HTTP__PORT) || Number(process.env.PORT) || 3000; + +export const http: HttpConfig = { + port: httpPort, + sessionSecret: process.env.HTTP__SESSION_SECRET || randomBytes(32).toString("hex"), +} + +export const gateway: GatewayConfig = { + url: process.env.GATEWAY__URL || "https://sms.capcom.me/api/3rdparty/v1", + webhookUrl: process.env.GATEWAY__WEBHOOK_URL || `http://localhost:${httpPort}/api/webhooks`, +} + +export const debug: boolean = process.env.NODE_ENV === "development"; diff --git a/src/index.ts b/src/index.ts index d11d1d1..ba19700 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,48 @@ -import express, { Express, Request, Response } from "express"; -import dotenv from "dotenv"; +import express, { Express } from "express"; +import { createServer } from "http"; +import morgan from "morgan"; +import { Server } from "socket.io"; -dotenv.config(); +import session from "./middlewares/session"; +import * as config from "./config"; +import * as api from "./routes/api"; +import * as root from "./routes/root"; + +const port: number = config.http.port; const app: Express = express(); -const port = process.env.PORT || 3000; +const server = createServer(app); +const io = new Server(server); + +const sessionMiddleware = session({ secret: config.http.sessionSecret }); + +if (config.debug) { + app.use(morgan("dev")); +} else { + app.use(morgan("combined")); +} + +io.engine.use(sessionMiddleware); +require("./socket.io").register(io); + +app.use("/api", api.router); +app.use("/", sessionMiddleware, root.router); + +server.listen(port, () => { + console.info(`[server] running at http://localhost:${port}`); +}); + +process.on('SIGINT', () => { + shutdown(); +}); -app.get("/", (req: Request, res: Response) => { - res.send("Express + TypeScript Server"); +process.on('SIGTERM', () => { + shutdown(); }); -app.listen(port, () => { - console.log(`[server]: Server is running at http://localhost:${port}`); -}); \ No newline at end of file +function shutdown() { + console.debug('[server] shutting down...'); + server.close(() => { + console.debug('[server] server closed') + }); +} \ No newline at end of file diff --git a/src/middlewares/session.ts b/src/middlewares/session.ts new file mode 100644 index 0000000..4000c2a --- /dev/null +++ b/src/middlewares/session.ts @@ -0,0 +1,13 @@ +import session from "express-session"; + +export default ({ secret }: { secret: string }) => { + return session({ + secret: secret, + resave: false, + saveUninitialized: true, + cookie: { + httpOnly: true, + secure: "auto", + }, + }); +} \ No newline at end of file diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 0000000..8d9048b --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,7 @@ +import express from "express"; + +export const router = express.Router(); + +router.use(express.json()); + +router.use('/webhooks', require('./webhooks').router) diff --git a/src/routes/api/webhooks.ts b/src/routes/api/webhooks.ts new file mode 100644 index 0000000..f2fe954 --- /dev/null +++ b/src/routes/api/webhooks.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { GatewayService } from "../../services/gateway"; + + +export const router = Router(); + +router.post("/", (req, res) => { + GatewayService.processWebhook(req.query.sessionId as string, req.body); + res.sendStatus(200); +}); \ No newline at end of file diff --git a/src/routes/root.ts b/src/routes/root.ts new file mode 100644 index 0000000..e3b41ad --- /dev/null +++ b/src/routes/root.ts @@ -0,0 +1,7 @@ +import express, { Request, Response } from "express"; + +export const router = express.Router(); + +router.get("/", (req: Request, res: Response) => { + res.sendFile("index.html", { root: "./static" }); +}); \ No newline at end of file diff --git a/src/services/gateway.ts b/src/services/gateway.ts new file mode 100644 index 0000000..7ec2f84 --- /dev/null +++ b/src/services/gateway.ts @@ -0,0 +1,65 @@ +import EventEmitter from "events"; + +import { GatewayApi, WebHookEvent, WebHookPayloadSmsReceived } from "../apis/gateway"; +import { gateway as config } from "../config"; + +export interface MessageEvent { + sessionId: string; + phoneNumber: string; + message: string; + receivedAt: Date; +} + +const WEBHOOK_ID = "web-client-ts"; + +const sessions: Record = { + +}; + +const events: EventEmitter<{ "message": MessageEvent[] }> = new EventEmitter(); + +const processWebhook = (sessionId: string, event: WebHookEvent) => { + if (event.event !== "sms:received") { + return; + } + + events.emit("message", { + sessionId: sessionId, + phoneNumber: event.payload.phoneNumber, + message: event.payload.message, + receivedAt: new Date(event.payload.receivedAt), + }); +} + +const login = async (sessionId: string, login: string, password: string) => { + if (!sessions[sessionId]) { + sessions[sessionId] = new GatewayApi(login, password); + } + + const api = sessions[sessionId]; + api.updateCredentials(login, password); + await api.webhookRegister(WEBHOOK_ID, `${config.webhookUrl}?sessionId=${sessionId}`); +} + +const subscribe = (callback: (event: MessageEvent) => void) => { + events.on("message", callback); +} + +const send = async (sessionId: string, phoneNumber: string, message: string) => { + const api = sessions[sessionId]; + if (!api) { + throw new Error(`Session ${sessionId} not found`); + } + + await api.sendMessage(phoneNumber, message); +} + +const logout = async (sessionId: string) => { + const api = sessions[sessionId]; + if (api) { + await api.webhookUnregister(WEBHOOK_ID); + } + delete sessions[sessionId]; +} + +export const GatewayService = { login, logout, send, subscribe, processWebhook } \ No newline at end of file diff --git a/src/socket.io/index.ts b/src/socket.io/index.ts new file mode 100644 index 0000000..e0501f1 --- /dev/null +++ b/src/socket.io/index.ts @@ -0,0 +1,87 @@ +import { Server } from "socket.io"; +import { SessionIncomingMessage } from "../types"; +import { UnauthorizedError } from "../apis/gateway"; +import { GatewayService } from "../services/gateway"; + +export const register = (io: Server) => { + GatewayService.subscribe((event) => { + io.to(event.sessionId).emit("sms:received", event); + }); + + io.on("connection", (socket) => { + const req = socket.request; + + socket.join(req.session.id); + + socket.use((data, next) => { + console.debug(data); + req.session.reload((err: any) => { + if (err) { + console.error(err); + socket.disconnect(); + } else { + next(); + } + }); + }); + + socket.on('login', async (data) => { + try { + await GatewayService.login(req.session.id, data.login, data.password); + req.session.login = data.login; + req.session.password = data.password; + req.session.save((err) => { + if (err) { + console.error(err); + } + }); + + socket.emit('login:success'); + } catch (error: Error | any) { + socket.emit('login:fail', { message: error.message }); + } + }); + + socket.on('sms:send', async (data, callback) => { + try { + await GatewayService.send(req.session.id, data.phoneNumber, data.message); + callback({ success: true }); + } catch (error: Error | any) { + callback({ success: false, message: error.message }); + + if (error instanceof UnauthorizedError) { + socket.emit('login:fail', { message: error.message }); + } + } + }); + + socket.on('logout', async () => { + try { + await GatewayService.logout(req.session.id); + + req.session.destroy((err) => { + if (err) { + console.error(err); + } + }); + } catch (e) { + console.error(e); + } + socket.emit('login:fail'); + }); + + console.log(`user connected: ${req.session.id}`); + if (req.session.login && req.session.password) { + socket.emit('login:success'); + } + + socket.on("disconnect", async () => { + try { + await GatewayService.logout(req.session.id); + } catch (e) { + console.error(e); + } + console.log(`user disconnected: ${req.session.id}`); + }); + }); +} \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..6ba842c --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,18 @@ +import type { IncomingMessage } from 'http'; +import type { SessionData } from 'express-session'; +import type { Socket } from 'socket.io'; + +declare module 'express-session' { + interface SessionData extends Session { + login: string + password: string + } +}; + +interface SessionIncomingMessage extends IncomingMessage { + session: SessionData +}; + +export interface SessionSocket extends Socket { + request: SessionIncomingMessage +}; \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..2c63951 --- /dev/null +++ b/static/index.html @@ -0,0 +1,137 @@ + + + + + + + SMS Manager + + + + + + + +
+
+
+
+ + +
+
+ + +
+ +
+

Login failed, please try again.

+
+ +
+
+ Loading... +
+
+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + + + \ No newline at end of file