├── android ├── app │ ├── .gitignore │ ├── src │ │ ├── main │ │ │ ├── ic_launcher-playstore.png │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ ├── drawable-hdpi │ │ │ │ │ └── ic_stat_name.png │ │ │ │ ├── drawable-mdpi │ │ │ │ │ └── ic_stat_name.png │ │ │ │ ├── drawable-xhdpi │ │ │ │ │ └── ic_stat_name.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ └── ic_launcher_round.webp │ │ │ │ ├── drawable-xxhdpi │ │ │ │ │ └── ic_stat_name.png │ │ │ │ ├── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ ├── colors.xml │ │ │ │ │ └── themes.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ ├── drawable │ │ │ │ │ ├── ic_pulse.xml │ │ │ │ │ ├── baseline_check_circle_24.xml │ │ │ │ │ ├── ic_login.xml │ │ │ │ │ ├── ic_colored_logo.xml │ │ │ │ │ └── baseline_settings_24.xml │ │ │ │ ├── xml │ │ │ │ │ ├── backup_rules.xml │ │ │ │ │ └── data_extraction_rules.xml │ │ │ │ └── values-night │ │ │ │ │ └── themes.xml │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── httpsms │ │ │ │ ├── Constants.kt │ │ │ │ ├── receivers │ │ │ │ └── BootReceiver.kt │ │ │ │ ├── validators │ │ │ │ └── PhoneNumberValidator.kt │ │ │ │ ├── worker │ │ │ │ └── HeartbeatWorker.kt │ │ │ │ ├── Models.kt │ │ │ │ └── Encrypter.kt │ │ ├── test │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── httpsms │ │ │ │ └── ExampleUnitTest.kt │ │ └── androidTest │ │ │ └── java │ │ │ └── com │ │ │ └── httpsms │ │ │ └── ExampleInstrumentedTest.kt │ ├── proguard-rules.pro │ └── google-services.json ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── build.gradle └── gradle.properties ├── web ├── .dockerignore ├── .prettierrc ├── .firebaserc ├── static │ ├── avatar.png │ ├── header.png │ ├── favicon.ico │ ├── robots.txt │ ├── logo-bg-none.png │ ├── templates │ │ ├── httpsms-bulk.xlsx │ │ └── httpsms-bulk.csv │ ├── img │ │ └── blog │ │ │ ├── send-sms-from-android-phone-with-python │ │ │ ├── header.png │ │ │ └── sms-sent.png │ │ │ ├── forward-incoming-sms-from-phone-to-webhook │ │ │ ├── header.png │ │ │ ├── webhook.png │ │ │ ├── settings.png │ │ │ └── android-app.png │ │ │ ├── grant-send-and-read-sms-permissions-on-android │ │ │ ├── allow.png │ │ │ ├── app-info.png │ │ │ └── allow-restricted-settings.png │ │ │ ├── end-to-end-encryption-to-sms-messages │ │ │ ├── send-sms-message.png │ │ │ └── encryption-key-android.png │ │ │ ├── send-bulk-sms-from-csv-file-with-no-code │ │ │ ├── bulk-csv-upload.png │ │ │ └── httpms-spreedsheet.png │ │ │ └── send-sms-when-new-row-is-added-to-google-sheets-using-zapier │ │ │ ├── google-sheets.png │ │ │ ├── zapier-trigger.png │ │ │ ├── zapier-action-event.png │ │ │ └── zapier-action-action.png │ └── integrations.js ├── assets │ ├── img │ │ ├── arnold.png │ │ ├── code-snippet.png │ │ ├── phone-login.png │ │ ├── httpsms-github.png │ │ ├── phone-api-key.png │ │ └── bulk-sms-template.png │ └── variables.scss ├── commitlint.config.js ├── plugins │ ├── vue-glow.ts │ ├── capitalize.ts │ ├── axios.ts │ ├── chart.ts │ ├── errors.ts │ ├── bag.ts │ └── veutify.ts ├── models │ ├── heartbeat.ts │ ├── billing.ts │ ├── message-thread.ts │ ├── user.ts │ └── message.ts ├── nginx.conf ├── firebase.json ├── .editorconfig ├── middleware │ ├── guest.ts │ └── auth.ts ├── .babelrc ├── test │ └── NuxtLogo.spec.js ├── .eslintrc.js ├── stylelint.config.js ├── types.d.ts ├── jest.config.js ├── Dockerfile ├── tsconfig.json ├── .env.docker ├── layouts │ └── error.vue ├── components │ ├── BackButton.vue │ ├── BlogAuthorBio.vue │ ├── BlogInfo.vue │ ├── LoadingDashboard.vue │ ├── FixedHeader.vue │ ├── NuxtLogo.vue │ ├── CopyButton.vue │ ├── Toast.vue │ └── LoadingButton.vue ├── .env.production ├── pages │ ├── threads │ │ └── index.vue │ └── login.vue └── .gitignore ├── .github ├── ghbadge.png ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── api ├── .gitignore ├── cmd │ ├── migration │ │ └── main.go │ ├── replay │ │ └── main.go │ └── fcm │ │ └── main.go ├── pkg │ ├── responses │ │ ├── user_responses.go │ │ ├── message_thead_responses.go │ │ ├── phone_responses.go │ │ ├── discord_responses.go │ │ ├── webhook_responses.go │ │ ├── message_responses.go │ │ ├── heartbeat_responses.go │ │ ├── billing_responses.go │ │ └── phone_api_key_responses.go │ ├── events │ │ ├── listeners.go │ │ ├── user_account_created_event.go │ │ ├── user_account_deleted_event.go │ │ ├── user_api_key_rotated_event.go │ │ ├── phone_deleted_event.go │ │ ├── message_send_expired_check_event.go │ │ ├── message_call_missed_event.go │ │ ├── phone_heartbeat_check_event.go │ │ ├── phone_updated_event.go │ │ ├── message_notification_send_event.go │ │ ├── phone_heartbeat_missed.go │ │ ├── message_send_retry_event.go │ │ ├── phone_heartbeat_online_event.go │ │ ├── phone_heartbeat_offline_event.go │ │ ├── message_phone_received_event.go │ │ ├── message_phone_sent_event.go │ │ ├── message_notification_failed_event.go │ │ ├── user_subscription_created_event.go │ │ ├── message_phone_delivered_event.go │ │ ├── message_phone_sending_event.go │ │ ├── user_subscription_canceled_event.go │ │ ├── message_send_failed_event.go │ │ ├── discord_message_failed_event.go │ │ ├── message_thead_api_deleted_event.go │ │ ├── user_subscription_expired_event.go │ │ ├── user_subscription_updated_event.go │ │ ├── webhook_event_failed_event.go │ │ ├── message_notification_sent_event.go │ │ ├── message_api_sent_event.go │ │ ├── message_notification_scheduled_event.go │ │ ├── message_send_expired_event.go │ │ └── message_api_deleted_event.go │ ├── cache │ │ ├── cache.go │ │ ├── memory_cache.go │ │ └── redis_cache.go │ ├── handlers │ │ └── handler_test.go │ ├── discord │ │ ├── client_config.go │ │ ├── response.go │ │ ├── guild_service.go │ │ ├── application_service.go │ │ ├── client_option.go │ │ ├── client_option_test.go │ │ └── channel_service.go │ ├── requests │ │ ├── phone_delete_request.go │ │ ├── phone_api_key_store_request.go │ │ ├── message_thread_update_request.go │ │ ├── billing_usage_history_request.go │ │ ├── user_notification_update_request.go │ │ ├── discord_update_request.go │ │ ├── webhook_update_request.go │ │ ├── phone_index_request.go │ │ ├── discord_index_request.go │ │ ├── webhook_index_request.go │ │ ├── phone_api_key_index_request.go │ │ ├── message_outstanding_request.go │ │ ├── discord_store_request.go │ │ ├── heartbeat_index_request.go │ │ ├── user_update_request.go │ │ ├── webhook_store_request.go │ │ ├── message_call_missed_request.go │ │ ├── integration_3cx_message_request.go │ │ ├── heartbeat_store_request.go │ │ ├── phone_fcm_token_request.go │ │ ├── bulk_message_request.go │ │ ├── message_event_request.go │ │ └── message_thread_index_request.go │ ├── entities │ │ ├── auth_context.go │ │ ├── heartbeat.go │ │ ├── integration_3cx.go │ │ ├── discord.go │ │ ├── webhook.go │ │ ├── phone_notification.go │ │ ├── heartbeat_monitor.go │ │ ├── billing_usage.go │ │ └── phone_api_key.go │ ├── di │ │ └── config.go │ ├── repositories │ │ ├── repository.go │ │ ├── integration_3cx_repository.go │ │ ├── phone_notification_repository.go │ │ ├── heartbeat_repository.go │ │ ├── phone_repository.go │ │ ├── billing_usage_repository.go │ │ ├── webhook_repository.go │ │ ├── discord_repository.go │ │ ├── heartbeat_monitor_repository.go │ │ ├── user_repository.go │ │ ├── message_thread_repository.go │ │ └── phone_api_key_repository.go │ ├── emails │ │ ├── mailer.go │ │ ├── factory.go │ │ ├── user_email_factory.go │ │ ├── hermes_mailer.go │ │ ├── notification_email_factory.go │ │ └── smtp_mailer_service.go │ ├── services │ │ ├── push_queue_service.go │ │ └── service.go │ ├── telemetry │ │ ├── logger.go │ │ ├── tracer.go │ │ └── gorm_logger.go │ ├── middlewares │ │ ├── authenticated_middlesare.go │ │ ├── http_request_logger_middleware.go │ │ ├── phone_api_key_auth_middleware.go │ │ ├── bearer_api_key_auth_middleware.go │ │ └── bearer_auth_middleware.go │ └── validators │ │ ├── user_handler_validator.go │ │ ├── billing_handler_validator.go │ │ └── lemonsqueezy_handler_validator.go ├── .env.production ├── .air.toml ├── cloudbuild.yaml ├── Dockerfile └── main.go ├── SECURITY.md ├── .pre-commit-config.yaml └── docker-compose.yml /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nuxt 4 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.github/ghbadge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/.github/ghbadge.png -------------------------------------------------------------------------------- /web/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "httpsms-86c51" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /web/static/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/avatar.png -------------------------------------------------------------------------------- /web/static/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/header.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [NdoleStudio] 4 | -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /web/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://httpsms.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /web/assets/img/arnold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/arnold.png -------------------------------------------------------------------------------- /web/commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /web/static/logo-bg-none.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/logo-bg-none.png -------------------------------------------------------------------------------- /web/assets/img/code-snippet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/code-snippet.png -------------------------------------------------------------------------------- /web/assets/img/phone-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/phone-login.png -------------------------------------------------------------------------------- /web/assets/img/httpsms-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/httpsms-github.png -------------------------------------------------------------------------------- /web/assets/img/phone-api-key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/phone-api-key.png -------------------------------------------------------------------------------- /web/assets/img/bulk-sms-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/assets/img/bulk-sms-template.png -------------------------------------------------------------------------------- /web/static/templates/httpsms-bulk.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/templates/httpsms-bulk.xlsx -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /web/plugins/vue-glow.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import VueGlow from 'vue-glow' 3 | import Vue from 'vue' 4 | Vue.component('VueGlow', VueGlow) 5 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /web/models/heartbeat.ts: -------------------------------------------------------------------------------- 1 | export interface Heartbeat { 2 | id: string 3 | owner: string 4 | charging: boolean 5 | timestamp: string 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .env* 3 | !.env.docker 4 | !.env.production 5 | *serviceAccountKey.json 6 | android/app/debug/ 7 | *main.exe* 8 | android/app/release/ 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_stat_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/drawable-hdpi/ic_stat_name.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_stat_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/drawable-mdpi/ic_stat_name.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_stat_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | .env.local 2 | *main.exe 3 | tmp 4 | $path 5 | 6 | .env* 7 | .flaskenv* 8 | !.env.project 9 | !.env.vault 10 | !.env.docker 11 | cmd/experiments/* 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /web/assets/variables.scss: -------------------------------------------------------------------------------- 1 | // Ref: https://github.com/nuxt-community/vuetify-module#customvariables 2 | // 3 | // The variables you want to modify 4 | // $font-size-root: 20px; 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | 5 | -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-from-android-phone-with-python/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-from-android-phone-with-python/header.png -------------------------------------------------------------------------------- /web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/header.png -------------------------------------------------------------------------------- /web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/webhook.png -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-from-android-phone-with-python/sms-sent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-from-android-phone-with-python/sms-sent.png -------------------------------------------------------------------------------- /web/static/templates/httpsms-bulk.csv: -------------------------------------------------------------------------------- 1 | FromPhoneNumber,ToPhoneNumber,Content 2 | +18005550199,+18005550100,This is a sample text message1 3 | +18005550199,+18005550100,This is a sample text message2 4 | -------------------------------------------------------------------------------- /web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/settings.png -------------------------------------------------------------------------------- /web/static/img/blog/grant-send-and-read-sms-permissions-on-android/allow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/grant-send-and-read-sms-permissions-on-android/allow.png -------------------------------------------------------------------------------- /web/static/img/blog/end-to-end-encryption-to-sms-messages/send-sms-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/end-to-end-encryption-to-sms-messages/send-sms-message.png -------------------------------------------------------------------------------- /web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/android-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/forward-incoming-sms-from-phone-to-webhook/android-app.png -------------------------------------------------------------------------------- /web/static/img/blog/grant-send-and-read-sms-permissions-on-android/app-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/grant-send-and-read-sms-permissions-on-android/app-info.png -------------------------------------------------------------------------------- /web/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3000; 3 | server_name localhost; 4 | root /usr/share/nginx/html; 5 | index index.html index.htm; 6 | location / { 7 | try_files $uri $uri/ /index.html; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /web/static/img/blog/send-bulk-sms-from-csv-file-with-no-code/bulk-csv-upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-bulk-sms-from-csv-file-with-no-code/bulk-csv-upload.png -------------------------------------------------------------------------------- /web/static/img/blog/end-to-end-encryption-to-sms-messages/encryption-key-android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/end-to-end-encryption-to-sms-messages/encryption-key-android.png -------------------------------------------------------------------------------- /web/static/img/blog/send-bulk-sms-from-csv-file-with-no-code/httpms-spreedsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-bulk-sms-from-csv-file-with-no-code/httpms-spreedsheet.png -------------------------------------------------------------------------------- /web/plugins/capitalize.ts: -------------------------------------------------------------------------------- 1 | export default function (value: string | null) { 2 | if (!value) { 3 | return '' 4 | } 5 | 6 | value = value.toString() 7 | 8 | return value.charAt(0).toUpperCase() + value.slice(1) 9 | } 10 | -------------------------------------------------------------------------------- /web/static/img/blog/grant-send-and-read-sms-permissions-on-android/allow-restricted-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/grant-send-and-read-sms-permissions-on-android/allow-restricted-settings.png -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/google-sheets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/google-sheets.png -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-trigger.png -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-action-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-action-event.png -------------------------------------------------------------------------------- /web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-action-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NdoleStudio/httpsms/HEAD/web/static/img/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier/zapier-action-action.png -------------------------------------------------------------------------------- /api/cmd/migration/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | func main() { 10 | err := godotenv.Load("../../.env") 11 | if err != nil { 12 | log.Fatal("Error loading .env file") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /api/pkg/responses/user_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // UserResponse is the payload containing entities.User 6 | type UserResponse struct { 7 | response 8 | Data entities.User `json:"data"` 9 | } 10 | -------------------------------------------------------------------------------- /web/models/billing.ts: -------------------------------------------------------------------------------- 1 | export interface BillingUsage { 2 | id: string 3 | start_timestamp: string 4 | end_timestamp: string 5 | user_id: string 6 | sent_messages: number 7 | received_messages: number 8 | total_cost: number 9 | created_at: string 10 | } 11 | -------------------------------------------------------------------------------- /web/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/200.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api/pkg/events/listeners.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "context" 5 | 6 | cloudevents "github.com/cloudevents/sdk-go/v2" 7 | ) 8 | 9 | // EventListener is the type for processing events 10 | type EventListener func(ctx context.Context, event cloudevents.Event) error 11 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /web/middleware/guest.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from '@nuxt/types' 2 | 3 | const guestMiddleware: Middleware = (context: Context) => { 4 | if (context.store.getters.getAuthUser !== null) { 5 | context.redirect('/threads') 6 | } 7 | } 8 | 9 | export default guestMiddleware 10 | -------------------------------------------------------------------------------- /web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web/test/NuxtLogo.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import NuxtLogo from '@/components/NuxtLogo.vue' 3 | 4 | describe('NuxtLogo', () => { 5 | test('is a Vue instance', () => { 6 | const wrapper = mount(NuxtLogo) 7 | expect(wrapper.vm).toBeTruthy() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jun 23 15:32:32 EEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /web/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from '@nuxt/types' 2 | 3 | const authMiddleware: Middleware = (context: Context) => { 4 | if (context.store.getters.getAuthUser === null) { 5 | context.redirect('/login', { to: context.route.path }) 6 | } 7 | } 8 | 9 | export default authMiddleware 10 | -------------------------------------------------------------------------------- /api/pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Cache stores items temporarily 9 | type Cache interface { 10 | Set(ctx context.Context, key string, value string, ttl time.Duration) error 11 | Get(ctx context.Context, key string) (value string, err error) 12 | } 13 | -------------------------------------------------------------------------------- /api/pkg/responses/message_thead_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // MessageThreadsResponse is the payload containing []entities.MessageThread 6 | type MessageThreadsResponse struct { 7 | response 8 | Data []entities.MessageThread `json:"data"` 9 | } 10 | -------------------------------------------------------------------------------- /web/models/message-thread.ts: -------------------------------------------------------------------------------- 1 | export interface MessageThread { 2 | color: string 3 | contact: string 4 | created_at: string 5 | id: string 6 | last_message_content: string 7 | last_message_id: string 8 | is_archived: boolean 9 | order_timestamp: string 10 | owner: string 11 | updated_at: string 12 | } 13 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | '@nuxtjs/eslint-config-typescript', 9 | 'plugin:nuxt/recommended', 10 | 'prettier', 11 | ], 12 | plugins: [], 13 | // add your custom rules here 14 | rules: {}, 15 | } 16 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | customSyntax: 'postcss-html', 3 | extends: [ 4 | 'stylelint-config-standard', 5 | 'stylelint-config-recommended-vue', 6 | 'stylelint-config-prettier', 7 | ], 8 | // add your custom config here 9 | // https://stylelint.io/user-guide/configuration 10 | rules: {}, 11 | } 12 | -------------------------------------------------------------------------------- /api/cmd/replay/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/di" 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | func main() { 11 | err := godotenv.Load("../../.env") 12 | if err != nil { 13 | log.Fatal("Error loading .env file") 14 | } 15 | 16 | _ = di.NewContainer("http-sms", "") 17 | } 18 | -------------------------------------------------------------------------------- /api/pkg/handlers/handler_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/carlmjohnson/requests" 7 | _ "github.com/joho/godotenv/autoload" // import USER_API_KEY from .env file 8 | ) 9 | 10 | func testClient() *requests.Builder { 11 | return requests.URL("http://localhost:8000"). 12 | Header("x-api-key", os.Getenv("USER_API_KEY")) 13 | } 14 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "httpSMS" 16 | include ':app' 17 | -------------------------------------------------------------------------------- /api/pkg/responses/phone_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // PhonesResponse is the payload containing entities.Phone 6 | type PhonesResponse struct { 7 | response 8 | Data []entities.Phone `json:"data"` 9 | } 10 | 11 | // PhoneResponse is the payload containing entities.Phone 12 | type PhoneResponse struct { 13 | response 14 | Data entities.Phone `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_pulse.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/httpsms/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/pkg/responses/discord_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // DiscordResponse is the payload containing entities.Discord 6 | type DiscordResponse struct { 7 | response 8 | Data entities.Discord `json:"data"` 9 | } 10 | 11 | // DiscordsResponse is the payload containing []entities.Discord 12 | type DiscordsResponse struct { 13 | response 14 | Data []entities.Discord `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /api/pkg/responses/webhook_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // WebhookResponse is the payload containing entities.Webhook 6 | type WebhookResponse struct { 7 | response 8 | Data entities.Webhook `json:"data"` 9 | } 10 | 11 | // WebhooksResponse is the payload containing []entities.Webhook 12 | type WebhooksResponse struct { 13 | response 14 | Data []entities.Webhook `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /api/pkg/discord/client_config.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import "net/http" 4 | 5 | type clientConfig struct { 6 | httpClient *http.Client 7 | botToken string 8 | applicationID string 9 | baseURL string 10 | } 11 | 12 | func defaultClientConfig() *clientConfig { 13 | return &clientConfig{ 14 | httpClient: http.DefaultClient, 15 | botToken: "", 16 | applicationID: "", 17 | baseURL: "https://discord.com/api", 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/pkg/responses/message_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // MessageResponse is the payload containing an entities.Message 6 | type MessageResponse struct { 7 | response 8 | Data entities.Message `json:"data"` 9 | } 10 | 11 | // MessagesResponse is the payload containing []entities.Message 12 | type MessagesResponse struct { 13 | response 14 | Data []entities.Message `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/baseline_check_circle_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /api/pkg/requests/phone_delete_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | // PhoneDelete is the payload for deleting 8 | type PhoneDelete struct { 9 | request 10 | PhoneID string `json:"phoneID" swaggerignore:"true"` // used internally for validation 11 | } 12 | 13 | // PhoneIDUuid returns the phoneID as uuid.UUID 14 | func (input *PhoneDelete) PhoneIDUuid() uuid.UUID { 15 | return uuid.MustParse(input.PhoneID) 16 | } 17 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_login.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /api/pkg/requests/phone_api_key_store_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | // PhoneAPIKeyStoreRequest is the payload for storing a phone API key 4 | type PhoneAPIKeyStoreRequest struct { 5 | request 6 | Name string `json:"name" example:"My Phone API Key"` 7 | } 8 | 9 | // Sanitize sets defaults to MessageReceive 10 | func (input *PhoneAPIKeyStoreRequest) Sanitize() PhoneAPIKeyStoreRequest { 11 | input.Name = input.sanitizeAddress(input.Name) 12 | return *input 13 | } 14 | -------------------------------------------------------------------------------- /api/pkg/responses/heartbeat_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // HeartbeatsResponse is the payload containing []entities.Heartbeat 6 | type HeartbeatsResponse struct { 7 | response 8 | Data []entities.Heartbeat `json:"data"` 9 | } 10 | 11 | // HeartbeatResponse is the payload containing entities.Heartbeat 12 | type HeartbeatResponse struct { 13 | response 14 | Data entities.Heartbeat `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /web/types.d.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat' 2 | import { Framework } from 'vuetify' 3 | 4 | interface Firebase { 5 | auth: firebase.auth.Auth 6 | appCheck: firebase.appCheck.AppCheck 7 | analytics: firebase.analytics.Analytics 8 | } 9 | 10 | export interface SelectItem { 11 | text: string 12 | value: string | number 13 | } 14 | 15 | declare module 'vue/types/vue' { 16 | interface Vue { 17 | $vuetify: Framework 18 | $fire: Firebase 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/static/integrations.js: -------------------------------------------------------------------------------- 1 | ;(function (c, l, a, r, i, t, y) { 2 | c[a] = 3 | c[a] || 4 | function () { 5 | ;(c[a].q = c[a].q || []).push(arguments) 6 | } 7 | t = l.createElement(r) 8 | t.async = 1 9 | t.src = 'https://www.clarity.ms/tag/' + i 10 | y = l.getElementsByTagName(r)[0] 11 | y.parentNode.insertBefore(t, y) 12 | })(window, document, 'clarity', 'script', 'f3xyl9wf6t') 13 | 14 | // LemonSqueezy 15 | window.lemonSqueezyAffiliateConfig = { store: 'httpsms' } 16 | -------------------------------------------------------------------------------- /api/pkg/responses/billing_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // BillingUsagesResponse is the payload containing []entities.BillingUsage 6 | type BillingUsagesResponse struct { 7 | response 8 | Data []entities.BillingUsage `json:"data"` 9 | } 10 | 11 | // BillingUsageResponse is the payload containing entities.BillingUsage 12 | type BillingUsageResponse struct { 13 | response 14 | Data entities.BillingUsage `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /api/pkg/responses/phone_api_key_responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "github.com/NdoleStudio/httpsms/pkg/entities" 4 | 5 | // PhoneAPIKeyResponse is the payload containing an entities.PhoneAPIKey 6 | type PhoneAPIKeyResponse struct { 7 | response 8 | Data *entities.PhoneAPIKey `json:"data"` 9 | } 10 | 11 | // PhoneAPIKeysResponse is the payload containing []entities.PhoneAPIKey 12 | type PhoneAPIKeysResponse struct { 13 | response 14 | Data []*entities.PhoneAPIKey `json:"data"` 15 | } 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | As of today, I only support the latest version of httpSMS in the `main` branch of github. Please make sure you stay up-to-date. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | main | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | Please report severe security issues privately via arnold@httpsms.com or by sending me a private message on [Discord](https://discord.gg/kGk8HVqeEZ). 14 | -------------------------------------------------------------------------------- /api/pkg/events/user_account_created_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserAccountCreated is raised when a user's account is created. 10 | const UserAccountCreated = "user.account.created" 11 | 12 | // UserAccountCreatedPayload stores the data for the UserAccountCreated event 13 | type UserAccountCreatedPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | Timestamp time.Time `json:"timestamp"` 16 | } 17 | -------------------------------------------------------------------------------- /api/pkg/entities/auth_context.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import "github.com/google/uuid" 4 | 5 | // AuthContext is the user gotten from an auth request 6 | type AuthContext struct { 7 | ID UserID `json:"id"` 8 | PhoneAPIKeyID *uuid.UUID `json:"phone_api_key_id"` 9 | PhoneNumbers []string `json:"phone_numbers"` 10 | Email string `json:"email"` 11 | } 12 | 13 | // IsNoop checks if a user is empty 14 | func (user AuthContext) IsNoop() bool { 15 | return user.ID == "" || user.Email == "" 16 | } 17 | -------------------------------------------------------------------------------- /api/.env.production: -------------------------------------------------------------------------------- 1 | ENV=production 2 | 3 | GCP_PROJECT_ID= 4 | 5 | APP_HTTP_LOGGER= 6 | 7 | EVENTS_QUEUE_TYPE= 8 | EVENTS_QUEUE_USER_API_KEY= 9 | EVENTS_QUEUE_NAME= 10 | EVENTS_QUEUE_USER_ID= 11 | EVENTS_QUEUE_ENDPOINT= 12 | 13 | FIREBASE_CREDENTIALS= 14 | 15 | SMTP_FROM_NAME= 16 | SMTP_FROM_EMAIL= 17 | SMTP_USERNAME= 18 | SMTP_PASSWORD= 19 | SMTP_HOST= 20 | SMTP_PORT= 21 | 22 | APP_URL= 23 | APP_NAME= 24 | APP_PORT=8000 25 | APP_HOST= 26 | 27 | DATABASE_URL= 28 | 29 | REDIS_URL= 30 | 31 | LEMONSQUEEZY_API_KEY= 32 | LEMONSQUEEZY_SIGNING_SECRET= 33 | -------------------------------------------------------------------------------- /web/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string 3 | email: string 4 | api_key: string 5 | active_phone_id: string | null 6 | subscription_ends_at: string 7 | /** @example "8f9c71b8-b84e-4417-8408-a62274f65a08" */ 8 | subscription_id: string 9 | /** @example "free" */ 10 | subscription_name: string 11 | /** @example "2022-06-05T14:26:02.302718+03:00" */ 12 | subscription_renews_at: string | null 13 | /** @example "on_trial" */ 14 | subscription_status: string 15 | created_at: string 16 | updated_at: string 17 | } 18 | -------------------------------------------------------------------------------- /web/plugins/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const client = axios.create({ 4 | baseURL: process.env.API_BASE_URL || 'http://localhost:8000', 5 | headers: { 6 | 'X-Client-Version': process.env.GITHUB_SHA || 'dev', 7 | }, 8 | }) 9 | 10 | export function setAuthHeader(token: string | null) { 11 | client.defaults.headers.common.Authorization = 'Bearer ' + token 12 | } 13 | 14 | export function setApiKey(apiKey: string | null) { 15 | client.defaults.headers.common['x-api-key'] = apiKey ?? '' 16 | } 17 | 18 | export default client 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/tekwizely/pre-commit-golang 3 | rev: v1.0.0-rc.1 4 | hooks: 5 | - id: go-fumpt 6 | - id: go-mod-tidy 7 | - id: go-lint 8 | - id: go-imports 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v4.4.0 11 | hooks: 12 | - id: check-yaml 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/pre-commit/mirrors-prettier 17 | rev: v3.0.0 18 | hooks: 19 | - id: prettier 20 | -------------------------------------------------------------------------------- /web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js', 6 | }, 7 | moduleFileExtensions: ['ts', 'js', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest', 10 | '^.+\\.js$': 'babel-jest', 11 | '.*\\.(vue)$': 'vue-jest', 12 | }, 13 | collectCoverage: true, 14 | collectCoverageFrom: [ 15 | '/components/**/*.vue', 16 | '/pages/**/*.vue', 17 | ], 18 | testEnvironment: 'jsdom', 19 | } 20 | -------------------------------------------------------------------------------- /api/pkg/di/config.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | // LoadEnv will read your .env file(s) and load them into ENV for this process. 11 | func LoadEnv(filenames ...string) { 12 | err := godotenv.Load(filenames...) 13 | if err != nil { 14 | log.Fatalf("Fatal: cannot load .env file: %v", err) 15 | } 16 | } 17 | 18 | func getEnvWithDefault(key, defaultValue string) string { 19 | value := os.Getenv(key) 20 | if value == "" { 21 | return defaultValue 22 | } 23 | 24 | return value 25 | } 26 | -------------------------------------------------------------------------------- /api/pkg/events/user_account_deleted_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserAccountDeleted is raised when a user's account is deleted. 10 | const UserAccountDeleted = "user.account.deleted" 11 | 12 | // UserAccountDeletedPayload stores the data for the UserAccountDeleted event 13 | type UserAccountDeletedPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | UserEmail string `json:"user_email"` 16 | Timestamp time.Time `json:"timestamp"` 17 | } 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /api/pkg/events/user_api_key_rotated_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserAPIKeyRotated is raised when a user's API key is rotated 10 | const UserAPIKeyRotated = "user.api-key.rotated" 11 | 12 | // UserAPIKeyRotatedPayload stores the data for the UserAPIKeyRotated event 13 | type UserAPIKeyRotatedPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | Email string `json:"email"` 16 | Timestamp time.Time `json:"timestamp"` 17 | Timezone string `json:"timezone"` 18 | } 19 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM node:lts-alpine as build 3 | 4 | WORKDIR /app 5 | 6 | COPY package.json pnpm-lock.yaml ./ 7 | 8 | # Install pnpm 9 | RUN npm install -g pnpm 10 | 11 | # install python 12 | RUN apk add --no-cache python3 13 | 14 | RUN pnpm install 15 | COPY . . 16 | RUN pnpm run generate 17 | 18 | # production stage 19 | FROM nginx:stable-alpine as production 20 | COPY --from=build /app/dist /usr/share/nginx/html 21 | 22 | # Copy the nginx configuration file 23 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 24 | 25 | EXPOSE 3000 26 | 27 | CMD ["nginx", "-g", "daemon off;"] 28 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"], 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "experimentalDecorators": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["./*"], 16 | "@/*": ["./*"] 17 | }, 18 | "types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "vuetify"] 19 | }, 20 | "exclude": ["node_modules", ".nuxt", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /web/plugins/chart.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Bar } from 'vue-chartjs' 3 | import { 4 | Chart as ChartJS, 5 | Title, 6 | Tooltip, 7 | Legend, 8 | BarElement, 9 | CategoryScale, 10 | LinearScale, 11 | TimeSeriesScale, 12 | LineElement, 13 | PointElement, 14 | ArcElement, 15 | TimeScale, 16 | } from 'chart.js' 17 | 18 | ChartJS.register( 19 | Title, 20 | Tooltip, 21 | Legend, 22 | PointElement, 23 | BarElement, 24 | TimeScale, 25 | TimeSeriesScale, 26 | CategoryScale, 27 | LinearScale, 28 | LineElement, 29 | ArcElement, 30 | ) 31 | 32 | Vue.component('BarChart', { 33 | extends: Bar, 34 | }) 35 | -------------------------------------------------------------------------------- /api/pkg/repositories/repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/palantir/stacktrace" 7 | ) 8 | 9 | // IndexParams parameters for indexing a database table 10 | type IndexParams struct { 11 | Skip int `json:"skip"` 12 | SortBy string `json:"sort"` 13 | SortDescending bool `json:"sort_descending"` 14 | Query string `json:"query"` 15 | Limit int `json:"take"` 16 | } 17 | 18 | const ( 19 | // ErrCodeNotFound is thrown when an entity does not exist in storage 20 | ErrCodeNotFound = stacktrace.ErrorCode(1000) 21 | 22 | dbOperationDuration = 5 * time.Second 23 | ) 24 | -------------------------------------------------------------------------------- /api/pkg/events/phone_deleted_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypePhoneDeleted is emitted when the phone os deleted 11 | const EventTypePhoneDeleted = "phone.deleted" 12 | 13 | // PhoneDeletedPayload is the payload of the EventTypePhoneDeleted event 14 | type PhoneDeletedPayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | Timestamp time.Time `json:"timestamp"` 18 | Owner string `json:"owner"` 19 | SIM entities.SIM `json:"sim"` 20 | } 21 | -------------------------------------------------------------------------------- /api/pkg/emails/mailer.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // Email represents an email message 10 | type Email struct { 11 | ToName string 12 | ToEmail string 13 | Subject string 14 | HTML string 15 | Text string 16 | } 17 | 18 | func (mail *Email) toAddress() string { 19 | if strings.TrimSpace(mail.ToName) != "" { 20 | return fmt.Sprintf("%s <%s>", mail.ToName, mail.ToEmail) 21 | } 22 | return mail.ToEmail 23 | } 24 | 25 | // Mailer is used for sending emails 26 | type Mailer interface { 27 | // Send adds a message to the push queue 28 | Send(ctx context.Context, mail *Email) error 29 | } 30 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | 20 | -------------------------------------------------------------------------------- /api/pkg/events/message_send_expired_check_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageSendExpiredCheck is emitted to trigger checking if a message is expired 12 | const EventTypeMessageSendExpiredCheck = "message.send.expired.check" 13 | 14 | // MessageSendExpiredCheckPayload is the payload of the EventTypeMessageSendExpiredCheck event 15 | type MessageSendExpiredCheckPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | ScheduledAt time.Time `json:"scheduled_at"` 18 | UserID entities.UserID `json:"user_id"` 19 | } 20 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #90CAF9 11 | #2196F3 12 | #1976D2 13 | #F50057 14 | #E91E63 15 | 16 | -------------------------------------------------------------------------------- /web/models/message.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | contact: string 3 | content: string 4 | created_at: string 5 | failure_reason: string 6 | id: string 7 | last_attempted_at: string | null 8 | order_timestamp: string 9 | owner: string 10 | received_at: string | null 11 | request_received_at: string | null 12 | send_time: number | null 13 | sent_at: string 14 | status: string 15 | type: string 16 | updated_at: string 17 | } 18 | 19 | export interface SearchMessagesRequest { 20 | owners: string[] 21 | types: string[] 22 | statuses: string[] 23 | query: string 24 | sort_by: string 25 | token?: string 26 | sort_descending: boolean 27 | skip: number 28 | limit: number 29 | } 30 | -------------------------------------------------------------------------------- /api/pkg/events/message_call_missed_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // MessageCallMissed is emitted when a new message is sent 12 | const MessageCallMissed = "message.call.missed" 13 | 14 | // MessageCallMissedPayload is the payload of the MessageCallMissed event 15 | type MessageCallMissedPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | Contact string `json:"contact"` 20 | Timestamp time.Time `json:"timestamp"` 21 | SIM entities.SIM `json:"sim"` 22 | } 23 | -------------------------------------------------------------------------------- /api/pkg/repositories/integration_3cx_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // Integration3CxRepository loads and persists an entities.Integration3CX 10 | type Integration3CxRepository interface { 11 | // Save an entities.Integration3CX 12 | Save(ctx context.Context, heartbeat *entities.Integration3CX) error 13 | 14 | // Load an entities.Integration3CX based on the entities.UserID 15 | Load(ctx context.Context, userID entities.UserID) (*entities.Integration3CX, error) 16 | 17 | // DeleteAllForUser deletes all entities.Integration3CX for a user 18 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 19 | } 20 | -------------------------------------------------------------------------------- /web/.env.docker: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:8000 2 | 3 | APP_URL=http://localhost:3000 4 | APP_NAME=httpSMS 5 | APP_GITHUB_URL=https://github.com/NdoleStudio/httpsms 6 | APP_DOCUMENTATION_URL=https://docs.httpsms.com 7 | APP_DOWNLOAD_URL=https://github.com/NdoleStudio/httpsms/releases/latest/download/HttpSms.apk 8 | APP_ENV=production 9 | 10 | # Firebase credentials 11 | FIREBASE_API_KEY=AIzaSyAKqPvj51igvvNNcRt_gL0A6cgx3ZB-kuQ 12 | FIREBASE_AUTH_DOMAIN=httpsms-docker.firebaseapp.com 13 | FIREBASE_PROJECT_ID=httpsms-docker 14 | FIREBASE_STORAGE_BUCKET=httpsms-docker.appspot.com 15 | FIREBASE_MESSAGING_SENDER_ID=668063041624 16 | FIREBASE_APP_ID=668063041624:web:29b9e3b7027965ba08a22d 17 | FIREBASE_MEASUREMENT_ID=G-18VRYL22PZ 18 | -------------------------------------------------------------------------------- /api/pkg/events/phone_heartbeat_check_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypePhoneHeartbeatCheck is emitted when the phone is missing a heartbeat 11 | const EventTypePhoneHeartbeatCheck = "phone.heartbeat.check" 12 | 13 | // PhoneHeartbeatCheckPayload is the payload of the EventTypePhoneHeartbeatCheck event 14 | type PhoneHeartbeatCheckPayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | ScheduledAt time.Time `json:"scheduled_at"` 18 | Owner string `json:"owner"` 19 | MonitorID uuid.UUID `json:"monitor_id"` 20 | } 21 | -------------------------------------------------------------------------------- /api/pkg/events/phone_updated_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypePhoneUpdated is emitted when the phone is updated 11 | const EventTypePhoneUpdated = "phone.updated" 12 | 13 | // PhoneUpdatedPayload is the payload of the EventTypePhoneUpdated event 14 | type PhoneUpdatedPayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | PhoneAPIKeyID *uuid.UUID `json:"phone_api_key_id"` 18 | Timestamp time.Time `json:"timestamp"` 19 | Owner string `json:"owner"` 20 | SIM entities.SIM `json:"sim"` 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/entities/heartbeat.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // Heartbeat represents is a pulse from an active phone 10 | type Heartbeat struct { 11 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 12 | Owner string `json:"owner" gorm:"index:idx_heartbeats_owner_timestamp" example:"+18005550199"` 13 | Version string `json:"version" example:"344c10f"` 14 | Charging bool `json:"charging" example:"true"` 15 | UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 16 | Timestamp time.Time `json:"timestamp" gorm:"index:idx_heartbeats_owner_timestamp" example:"2022-06-05T14:26:01.520828+03:00"` 17 | } 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_colored_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /api/pkg/emails/factory.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/nyaruka/phonenumbers" 8 | ) 9 | 10 | type factory struct{} 11 | 12 | func (factory *factory) formatPhoneNumber(number string) string { 13 | value, _ := phonenumbers.Parse(number, phonenumbers.UNKNOWN_REGION) 14 | return phonenumbers.Format(value, phonenumbers.INTERNATIONAL) 15 | } 16 | 17 | func (factory *factory) formatBool(value bool) string { 18 | if value == true { 19 | return "Yes" 20 | } 21 | return "No" 22 | } 23 | 24 | func (factory *factory) formatHTTPResponseCode(code *int) string { 25 | responseCode := "-" 26 | if code != nil { 27 | responseCode = fmt.Sprintf("%d - %s", *code, http.StatusText(*code)) 28 | } 29 | return responseCode 30 | } 31 | -------------------------------------------------------------------------------- /api/pkg/events/message_notification_send_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageNotificationSend is emitted when we are to send a phone notification 12 | const EventTypeMessageNotificationSend = "message.notification.send" 13 | 14 | // MessageNotificationSendPayload is the payload of the EventTypeMessageNotificationSend event 15 | type MessageNotificationSendPayload struct { 16 | MessageID uuid.UUID `json:"id"` 17 | UserID entities.UserID `json:"user_id"` 18 | PhoneID uuid.UUID `json:"phone_id"` 19 | ScheduledAt time.Time `json:"scheduled_at"` 20 | NotificationID uuid.UUID `json:"notification_id"` 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/services/push_queue_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/entities" 8 | ) 9 | 10 | // PushQueueTask represents a push queue task 11 | type PushQueueTask struct { 12 | Method string 13 | URL string 14 | Body []byte 15 | Headers map[string]string 16 | } 17 | 18 | // PushQueueConfig configurations for the push queue 19 | type PushQueueConfig struct { 20 | Name string 21 | UserAPIKey string 22 | UserID entities.UserID 23 | ConsumerEndpoint string 24 | } 25 | 26 | // PushQueue is a push queue 27 | type PushQueue interface { 28 | // Enqueue adds a message to the push queue 29 | Enqueue(ctx context.Context, task *PushQueueTask, timeout time.Duration) (string, error) 30 | } 31 | -------------------------------------------------------------------------------- /android/app/src/androidTest/java/com/httpsms/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.httpsms", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/repositories/phone_notification_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // PhoneNotificationRepository loads and persists an entities.PhoneNotification 12 | type PhoneNotificationRepository interface { 13 | // Schedule a new entities.PhoneNotification 14 | Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error 15 | 16 | // UpdateStatus of a notification 17 | UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error 18 | 19 | // DeleteAllForUser deletes all entities.PhoneNotification for a user 20 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 21 | } 22 | -------------------------------------------------------------------------------- /api/.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | bin = "tmp\\main.exe" 7 | cmd = "go build -o ./tmp/main.exe ." 8 | delay = 1000 9 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 10 | exclude_file = [] 11 | exclude_regex = ["_test.go"] 12 | exclude_unchanged = false 13 | follow_symlink = false 14 | full_bin = "" 15 | include_dir = [] 16 | include_ext = ["go", "tpl", "tmpl", "html"] 17 | kill_delay = "0s" 18 | log = "build-errors.log" 19 | send_interrupt = false 20 | stop_on_error = true 21 | 22 | [color] 23 | app = "" 24 | build = "yellow" 25 | main = "magenta" 26 | runner = "green" 27 | watcher = "cyan" 28 | 29 | [log] 30 | time = false 31 | 32 | [misc] 33 | clean_on_exit = false 34 | 35 | [screen] 36 | clear_on_rebuild = false 37 | -------------------------------------------------------------------------------- /api/pkg/events/phone_heartbeat_missed.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // PhoneHeartbeatMissed is emitted when the phone is missing a heartbeat 11 | const PhoneHeartbeatMissed = "phone.heartbeat.missed" 12 | 13 | // PhoneHeartbeatMissedPayload is the payload of the PhoneHeartbeatMissed event 14 | type PhoneHeartbeatMissedPayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | LastHeartbeatTimestamp time.Time `json:"last_heartbeat_timestamp"` 18 | Timestamp time.Time `json:"timestamp"` 19 | MonitorID uuid.UUID `json:"monitor_id"` 20 | Owner string `json:"owner"` 21 | } 22 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /api/pkg/entities/integration_3cx.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // Integration3CX stores the discord integration of a user 10 | type Integration3CX struct { 11 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 12 | UserID UserID `json:"user_id" gorm:"index" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 13 | WebhookURL string `json:"webhook_url" example:"https://org.3cx.com.au/sms/generic/123"` 14 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 15 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` 16 | } 17 | 18 | // TableName overrides the table name used by Integration3CX 19 | func (Integration3CX) TableName() string { 20 | return "integration_3cx" 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/repositories/heartbeat_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // HeartbeatRepository loads and persists an entities.Heartbeat 10 | type HeartbeatRepository interface { 11 | // Store a new entities.Heartbeat 12 | Store(ctx context.Context, heartbeat *entities.Heartbeat) error 13 | 14 | // Index entities.Heartbeat of an owner 15 | Index(ctx context.Context, userID entities.UserID, owner string, params IndexParams) (*[]entities.Heartbeat, error) 16 | 17 | // Last entities.Heartbeat returns the last heartbeat 18 | Last(ctx context.Context, userID entities.UserID, owner string) (*entities.Heartbeat, error) 19 | 20 | // DeleteAllForUser deletes all entities.Heartbeat for a user 21 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 22 | } 23 | -------------------------------------------------------------------------------- /api/pkg/events/message_send_retry_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageSendRetry is emitted when the phone a message expires and is being retried 12 | const EventTypeMessageSendRetry = "message.send.retry" 13 | 14 | // MessageSendRetryPayload is the payload of the EventTypeMessageSendRetry event 15 | type MessageSendRetryPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | Owner string `json:"owner"` 18 | Contact string `json:"contact"` 19 | Encrypted bool `json:"encrypted"` 20 | UserID entities.UserID `json:"user_id"` 21 | Timestamp time.Time `json:"timestamp"` 22 | Content string `json:"content"` 23 | SIM entities.SIM `json:"sim"` 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/events/phone_heartbeat_online_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypePhoneHeartbeatOnline is emitted when the phone is missing a heartbeat 11 | const EventTypePhoneHeartbeatOnline = "phone.heartbeat.online" 12 | 13 | // PhoneHeartbeatOnlinePayload is the payload of the EventTypePhoneHeartbeatOnline event 14 | type PhoneHeartbeatOnlinePayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | LastHeartbeatTimestamp time.Time `json:"last_heartbeat_timestamp"` 18 | Timestamp time.Time `json:"timestamp"` 19 | MonitorID uuid.UUID `json:"monitor_id"` 20 | Owner string `json:"owner"` 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/events/phone_heartbeat_offline_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypePhoneHeartbeatOffline is emitted when the phone is missing a heartbeat 11 | const EventTypePhoneHeartbeatOffline = "phone.heartbeat.offline" 12 | 13 | // PhoneHeartbeatOfflinePayload is the payload of the EventTypePhoneHeartbeatOffline event 14 | type PhoneHeartbeatOfflinePayload struct { 15 | PhoneID uuid.UUID `json:"phone_id"` 16 | UserID entities.UserID `json:"user_id"` 17 | LastHeartbeatTimestamp time.Time `json:"last_heartbeat_timestamp"` 18 | Timestamp time.Time `json:"timestamp"` 19 | MonitorID uuid.UUID `json:"monitor_id"` 20 | Owner string `json:"owner"` 21 | } 22 | -------------------------------------------------------------------------------- /web/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 44 | -------------------------------------------------------------------------------- /api/pkg/emails/user_email_factory.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserEmailFactory generates emails to a user 10 | type UserEmailFactory interface { 11 | // PhoneDead sends an emails when the user's phone is not sending heartbeats 12 | PhoneDead(user *entities.User, lastHeartbeatTimestamp time.Time, owner string) (*Email, error) 13 | 14 | // UsageLimitExceeded sends an email when the user's limit is exceeded 15 | UsageLimitExceeded(user *entities.User) (*Email, error) 16 | 17 | // UsageLimitAlert sends an email when a user is approaching the limit 18 | UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error) 19 | 20 | // APIKeyRotated sends an email when the API key is rotated 21 | APIKeyRotated(email string, timestamp time.Time, timezone string) (*Email, error) 22 | } 23 | -------------------------------------------------------------------------------- /api/pkg/events/message_phone_received_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessagePhoneReceived is emitted when a new message is received by a mobile phone 12 | const EventTypeMessagePhoneReceived = "message.phone.received" 13 | 14 | // MessagePhoneReceivedPayload is the payload of the EventTypeMessagePhoneReceived event 15 | type MessagePhoneReceivedPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | Encrypted bool `json:"encrypted"` 20 | Contact string `json:"contact"` 21 | Timestamp time.Time `json:"timestamp"` 22 | Content string `json:"content"` 23 | SIM entities.SIM `json:"sim"` 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/events/message_phone_sent_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessagePhoneSent is emitted when the phone sends a message 12 | const EventTypeMessagePhoneSent = "message.phone.sent" 13 | 14 | // MessagePhoneSentPayload is the payload of the EventTypeMessagePhoneSent event 15 | type MessagePhoneSentPayload struct { 16 | ID uuid.UUID `json:"id"` 17 | UserID entities.UserID `json:"user_id"` 18 | RequestID *string `json:"request_id"` 19 | Owner string `json:"owner"` 20 | Contact string `json:"contact"` 21 | Encrypted bool `json:"encrypted"` 22 | Timestamp time.Time `json:"timestamp"` 23 | Content string `json:"content"` 24 | SIM entities.SIM `json:"sim"` 25 | } 26 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms 2 | 3 | class Constants { 4 | companion object { 5 | const val KEY_MESSAGE_ID = "KEY_MESSAGE_ID" 6 | const val KEY_MESSAGE_FROM = "KEY_MESSAGE_FROM" 7 | const val KEY_MESSAGE_TO = "KEY_MESSAGE_TO" 8 | const val KEY_MESSAGE_SIM = "KEY_MESSAGE_SIM" 9 | const val KEY_MESSAGE_CONTENT = "KEY_MESSAGE_CONTENT" 10 | const val KEY_MESSAGE_TIMESTAMP = "KEY_MESSAGE_TIMESTAMP" 11 | const val KEY_MESSAGE_REASON = "KEY_MESSAGE_REASON" 12 | const val KEY_MESSAGE_ENCRYPTED = "KEY_MESSAGE_ENCRYPTED" 13 | 14 | 15 | const val KEY_HEARTBEAT_ID = "KEY_HEARTBEAT_ID" 16 | 17 | const val SIM1 = "SIM1" 18 | const val SIM2 = "SIM2" 19 | 20 | const val TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'000000'ZZZZZ" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | ext { 4 | kotlin_version = '2.1.0' 5 | } 6 | repositories { 7 | // Check that you have the following line (if not, add it): 8 | google() 9 | mavenCentral() // Google's Maven repository 10 | 11 | } 12 | dependencies { 13 | // Add this line 14 | classpath 'com.google.gms:google-services:4.4.2' 15 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 16 | } 17 | } 18 | 19 | plugins { 20 | id 'com.android.application' version '8.9.2' apply false 21 | id 'com.android.library' version '8.9.2' apply false 22 | id 'org.jetbrains.kotlin.android' version '1.6.21' apply false 23 | } 24 | 25 | tasks.register('clean', Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /api/pkg/discord/response.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | // Response captures the http response 11 | type Response struct { 12 | HTTPResponse *http.Response 13 | Body *[]byte 14 | } 15 | 16 | // Error ensures that the response can be decoded into a string inc ase it's an error response 17 | func (r *Response) Error() error { 18 | switch r.HTTPResponse.StatusCode { 19 | case 200, 201, 202, 204, 205: 20 | return nil 21 | default: 22 | return errors.New(r.errorMessage()) 23 | } 24 | } 25 | 26 | func (r *Response) errorMessage() string { 27 | var buf bytes.Buffer 28 | buf.WriteString(strconv.Itoa(r.HTTPResponse.StatusCode)) 29 | buf.WriteString(": ") 30 | buf.WriteString(http.StatusText(r.HTTPResponse.StatusCode)) 31 | buf.WriteString(", Body: ") 32 | buf.Write(*r.Body) 33 | 34 | return buf.String() 35 | } 36 | -------------------------------------------------------------------------------- /api/pkg/emails/hermes_mailer.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/matcornic/hermes" 9 | ) 10 | 11 | // HermesGeneratorConfig contains details for the generator 12 | type HermesGeneratorConfig struct { 13 | AppURL string 14 | AppName string 15 | AppLogoURL string 16 | } 17 | 18 | // Generator creates hermes.Hermes from HermesGeneratorConfig 19 | func (config *HermesGeneratorConfig) Generator() hermes.Hermes { 20 | return hermes.Hermes{ 21 | Theme: newHermesTheme(), 22 | Product: hermes.Product{ 23 | // Appears in header & footer of e-mails 24 | Name: fmt.Sprintf("The %s Team", config.AppName), 25 | Link: config.AppURL, 26 | // Optional product logo 27 | Copyright: fmt.Sprintf("© %s %s. All rights reserved.", strconv.Itoa(time.Now().Year()), config.AppName), 28 | Logo: config.AppLogoURL, 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/pkg/entities/discord.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // Discord stores the discord integration of a user 10 | type Discord struct { 11 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 12 | UserID UserID `json:"user_id" gorm:"index" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 13 | Name string `json:"name" example:"Game Server"` 14 | ServerID string `json:"server_id" gorm:"uniqueIndex:idx_discords_server_id;NOT NULL" example:"1095778291488653372"` 15 | IncomingChannelID string `json:"incoming_channel_id" example:"1095780203256627291"` 16 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 17 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` 18 | } 19 | -------------------------------------------------------------------------------- /api/cmd/fcm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "firebase.google.com/go/messaging" 10 | "github.com/NdoleStudio/httpsms/pkg/di" 11 | "github.com/joho/godotenv" 12 | ) 13 | 14 | func main() { 15 | err := godotenv.Load("../../.env") 16 | if err != nil { 17 | log.Fatal("Error loading .env file") 18 | } 19 | 20 | container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), "") 21 | client := container.FirebaseMessagingClient() 22 | 23 | result, err := client.Send(context.Background(), &messaging.Message{ 24 | Data: map[string]string{ 25 | "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), 26 | }, 27 | Android: &messaging.AndroidConfig{ 28 | Priority: "high", 29 | }, 30 | Token: os.Getenv("FIREBASE_TOKEN"), 31 | }) 32 | if err != nil { 33 | container.Logger().Fatal(err) 34 | } 35 | 36 | container.Logger().Info(result) 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/events/message_notification_failed_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageNotificationFailed is emitted when a new message notification is failed 12 | const EventTypeMessageNotificationFailed = "message.notification.failed" 13 | 14 | // MessageNotificationFailedPayload is the payload of the EventTypeMessageNotificationFailed event 15 | type MessageNotificationFailedPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | NotificationID uuid.UUID `json:"notification_id"` 19 | PhoneID uuid.UUID `json:"phone_id"` 20 | ErrorMessage string `json:"error_message"` 21 | NotificationFailedAt time.Time `json:"notification_failed_at"` 22 | } 23 | -------------------------------------------------------------------------------- /api/pkg/events/user_subscription_created_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserSubscriptionCreated is raised when a user subscription is created 10 | const UserSubscriptionCreated = "user.subscription.created" 11 | 12 | // UserSubscriptionCreatedPayload stores the data for the user created event 13 | type UserSubscriptionCreatedPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | SubscriptionCreatedAt time.Time `json:"subscription_created_at"` 16 | SubscriptionID string `json:"subscription_id"` 17 | SubscriptionName entities.SubscriptionName `json:"subscription_name"` 18 | SubscriptionRenewsAt time.Time `json:"subscription_renews_at"` 19 | SubscriptionStatus string `json:"subscription_status"` 20 | } 21 | -------------------------------------------------------------------------------- /api/pkg/events/message_phone_delivered_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessagePhoneDelivered is emitted when the phone delivers a message 12 | const EventTypeMessagePhoneDelivered = "message.phone.delivered" 13 | 14 | // MessagePhoneDeliveredPayload is the payload of the EventTypeMessagePhoneDelivered event 15 | type MessagePhoneDeliveredPayload struct { 16 | ID uuid.UUID `json:"id"` 17 | Owner string `json:"owner"` 18 | Contact string `json:"contact"` 19 | RequestID *string `json:"request_id"` 20 | UserID entities.UserID `json:"user_id"` 21 | Encrypted bool `json:"encrypted"` 22 | Timestamp time.Time `json:"timestamp"` 23 | Content string `json:"content"` 24 | SIM entities.SIM `json:"sim"` 25 | } 26 | -------------------------------------------------------------------------------- /api/pkg/events/message_phone_sending_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypeMessagePhoneSending is emitted when a message is picked up by the phone and is being sent 11 | const EventTypeMessagePhoneSending = "message.phone.sending" 12 | 13 | // MessagePhoneSendingPayload is the payload of the EventTypeMessageSent event 14 | type MessagePhoneSendingPayload struct { 15 | ID uuid.UUID `json:"id"` 16 | UserID entities.UserID `json:"user_id"` 17 | RequestID *string `json:"request_id"` 18 | Timestamp time.Time `json:"timestamp"` 19 | Owner string `json:"owner"` 20 | Encrypted bool `json:"encrypted"` 21 | Contact string `json:"contact"` 22 | Content string `json:"content"` 23 | SIM entities.SIM `json:"sim"` 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/requests/message_thread_update_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/google/uuid" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/services" 8 | ) 9 | 10 | // MessageThreadUpdate is the payload for updating a message thread 11 | type MessageThreadUpdate struct { 12 | request 13 | IsArchived bool `json:"is_archived" example:"true"` 14 | 15 | MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` // used internally for validation 16 | } 17 | 18 | // ToUpdateParams converts MessageThreadUpdate to services.MessageThreadStatusParams 19 | func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) services.MessageThreadStatusParams { 20 | return services.MessageThreadStatusParams{ 21 | UserID: userID, 22 | MessageThreadID: uuid.MustParse(input.MessageThreadID), 23 | IsArchived: input.IsArchived, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/kaniko-project/executor:v1.23.2" 3 | id: "Build image and push" 4 | dir: "api" 5 | args: 6 | - "--destination=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:$SHORT_SHA" 7 | - "--destination=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:latest" 8 | - "--dockerfile=Dockerfile" 9 | - "--context=." 10 | - "--build-arg=GIT_COMMIT=$SHORT_SHA" 11 | - "--snapshot-mode=time" 12 | 13 | - id: "Deploy to cloud run" 14 | name: "gcr.io/cloud-builders/gcloud" 15 | entrypoint: "bash" 16 | args: 17 | - "-c" 18 | - | 19 | gcloud run deploy $_SERVICE_NAME \ 20 | --image=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:$SHORT_SHA \ 21 | --region=$_REGION --platform managed --allow-unauthenticated \ 22 | --port=8000 23 | options: 24 | substitutionOption: ALLOW_LOOSE 25 | 26 | substitutions: 27 | _SERVICE_NAME: http-sms-api 28 | _REGION: us-east1 29 | -------------------------------------------------------------------------------- /api/pkg/events/user_subscription_canceled_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserSubscriptionCancelled is raised when a user subscription is cancelled 10 | const UserSubscriptionCancelled = "user.subscription.cancelled" 11 | 12 | // UserSubscriptionCancelledPayload stores the data for the UserSubscriptionCancelled event 13 | type UserSubscriptionCancelledPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | SubscriptionCancelledAt time.Time `json:"subscription_cancelled_at"` 16 | SubscriptionEndsAt time.Time `json:"subscription_ends_at"` 17 | SubscriptionID string `json:"subscription_id"` 18 | SubscriptionName entities.SubscriptionName `json:"subscription_name"` 19 | SubscriptionStatus string `json:"subscription_status"` 20 | } 21 | -------------------------------------------------------------------------------- /api/pkg/discord/guild_service.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // GuildService is the API client for interacting with guilds 11 | type GuildService service 12 | 13 | // Get the guild object for the given id. 14 | // 15 | // API Docs: https://discord.com/developers/docs/resources/guild#get-guild 16 | func (service *GuildService) Get(ctx context.Context, guildID string) (*map[string]any, *Response, error) { 17 | request, err := service.client.newRequest(ctx, http.MethodGet, fmt.Sprintf("/guilds/%s", guildID), nil) 18 | if err != nil { 19 | return nil, nil, err 20 | } 21 | 22 | response, err := service.client.do(request) 23 | if err != nil { 24 | return nil, response, err 25 | } 26 | 27 | channel := new(map[string]any) 28 | if err = json.Unmarshal(*response.Body, channel); err != nil { 29 | return nil, response, err 30 | } 31 | 32 | return channel, response, nil 33 | } 34 | -------------------------------------------------------------------------------- /api/pkg/events/message_send_failed_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // EventTypeMessageSendFailed is emitted when the phone could not send 11 | const EventTypeMessageSendFailed = "message.send.failed" 12 | 13 | // MessageSendFailedPayload is the payload of the EventTypeMessageSendFailed event 14 | type MessageSendFailedPayload struct { 15 | ID uuid.UUID `json:"id"` 16 | ErrorMessage string `json:"error_message"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | RequestID *string `json:"request_id"` 20 | Contact string `json:"contact"` 21 | Timestamp time.Time `json:"timestamp"` 22 | Encrypted bool `json:"encrypted"` 23 | Content string `json:"content"` 24 | SIM entities.SIM `json:"sim"` 25 | } 26 | -------------------------------------------------------------------------------- /web/components/BackButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | -------------------------------------------------------------------------------- /api/pkg/events/discord_message_failed_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | // EventTypeDiscordSendFailed is emitted when we can't send a discord message 9 | const EventTypeDiscordSendFailed = "discord.send.failed" 10 | 11 | // DiscordSendFailedPayload is the payload of the EventTypeDiscordSendFailed event 12 | type DiscordSendFailedPayload struct { 13 | DiscordID uuid.UUID `json:"discord_id"` 14 | UserID entities.UserID `json:"user_id"` 15 | MessageID uuid.UUID `json:"message_id"` 16 | EventType string `json:"event_type"` 17 | Owner string `json:"owner"` 18 | HTTPResponseStatusCode *int `json:"http_response_status_code"` 19 | ErrorMessage string `json:"error_message"` 20 | DiscordChannelID string `json:"discord_channel_id"` 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/events/message_thead_api_deleted_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // MessageThreadAPIDeleted is emitted when a new message is deleted 12 | const MessageThreadAPIDeleted = "message-thread.api.deleted" 13 | 14 | // MessageThreadAPIDeletedPayload is the payload of the MessageThreadAPIDeleted event 15 | type MessageThreadAPIDeletedPayload struct { 16 | MessageThreadID uuid.UUID `json:"message_thread_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | Contact string `json:"contact"` 20 | IsArchived bool `json:"is_archived"` 21 | Color string `json:"color"` 22 | Status entities.MessageStatus `json:"status"` 23 | Timestamp time.Time `json:"timestamp"` 24 | } 25 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang as builder 2 | 3 | ARG GIT_COMMIT 4 | ENV GIT_COMMIT=$GIT_COMMIT 5 | 6 | WORKDIR /http-sms 7 | 8 | COPY go.mod . 9 | COPY go.sum . 10 | 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | RUN go get github.com/swaggo/swag/gen@latest 16 | RUN go get github.com/swaggo/swag/cmd/swag@latest 17 | RUN go install github.com/swaggo/swag/cmd/swag 18 | RUN swag init --requiredByDefault --parseDependency --parseInternal 19 | 20 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$GIT_COMMIT" -o /bin/http-sms . 21 | 22 | FROM alpine:latest 23 | 24 | RUN addgroup -S http-sms && adduser -S http-sms -G http-sms 25 | 26 | USER http-sms 27 | WORKDIR /home/http-sms 28 | 29 | COPY --from=builder /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip 30 | COPY --from=builder /bin/http-sms ./ 31 | COPY --from=builder /http-sms/root.crt ./ 32 | 33 | ENV ZONEINFO=/zoneinfo.zip 34 | 35 | EXPOSE 8000 36 | 37 | ENTRYPOINT ["./http-sms", "--dotenv=false"] 38 | -------------------------------------------------------------------------------- /api/pkg/events/user_subscription_expired_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserSubscriptionExpired is raised when a user subscription is cancelled 10 | const UserSubscriptionExpired = "user.subscription.expired" 11 | 12 | // UserSubscriptionExpiredPayload stores the data for the UserSubscriptionExpired event 13 | type UserSubscriptionExpiredPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | SubscriptionExpiredAt time.Time `json:"subscription_expired_at"` 16 | SubscriptionEndsAt time.Time `json:"subscription_ends_at"` 17 | IsCancelled bool `json:"is_cancelled"` 18 | SubscriptionID string `json:"subscription_id"` 19 | SubscriptionName entities.SubscriptionName `json:"subscription_name"` 20 | SubscriptionStatus string `json:"subscription_status"` 21 | } 22 | -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | API_BASE_URL=https://api.httpsms.com 2 | 3 | APP_URL=https://httpsms.com 4 | APP_NAME=httpSMS 5 | APP_GITHUB_URL=https://github.com/NdoleStudio/httpsms 6 | APP_DOCUMENTATION_URL=https://docs.httpsms.com 7 | APP_DOWNLOAD_URL=https://apk.httpsms.com/HttpSms.apk 8 | APP_ENV=production 9 | 10 | CHECKOUT_URL=https://httpsms.lemonsqueezy.com/checkout/buy/706c5638-4c8d-40db-a6f2-b6371b7e0af4 11 | ENTERPRISE_CHECKOUT_URL=https://httpsms.lemonsqueezy.com/checkout/buy/d107cf05-4b13-4ebd-a770-c2cc75c69a14 12 | 13 | FIREBASE_API_KEY=AIzaSyClL8AX2H_F77_n8yu5FgLzBmJTiSM0NsQ 14 | FIREBASE_AUTH_DOMAIN=httpsms-86c51.firebaseapp.com 15 | FIREBASE_PROJECT_ID=httpsms-86c51 16 | FIREBASE_STORAGE_BUCKET=httpsms-86c51.appspot.com 17 | FIREBASE_MESSAGING_SENDER_ID=877524083399 18 | FIREBASE_APP_ID=1:877524083399:web:430d6a29a0d808946514e2 19 | FIREBASE_MEASUREMENT_ID=G-EZ5W9DVK8T 20 | 21 | CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA6Hpp8SDyMMPhWg 22 | 23 | PUSHER_KEY=a4809008d8f03aaab022 24 | PUSHER_CLUSTER=mt1 25 | -------------------------------------------------------------------------------- /web/components/BlogAuthorBio.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | -------------------------------------------------------------------------------- /api/pkg/events/user_subscription_updated_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserSubscriptionUpdated is raised when a user subscription is updated 10 | const UserSubscriptionUpdated = "user.subscription.updated" 11 | 12 | // UserSubscriptionUpdatedPayload stores the data for the UserSubscriptionUpdated event 13 | type UserSubscriptionUpdatedPayload struct { 14 | UserID entities.UserID `json:"user_id"` 15 | SubscriptionUpdatedAt time.Time `json:"subscription_updated_at"` 16 | SubscriptionEndsAt *time.Time `json:"subscription_ends_at"` 17 | SubscriptionRenewsAt time.Time `json:"subscription_renews_at"` 18 | SubscriptionID string `json:"subscription_id"` 19 | SubscriptionName entities.SubscriptionName `json:"subscription_name"` 20 | SubscriptionStatus string `json:"subscription_status"` 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/emails/notification_email_factory.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/NdoleStudio/httpsms/pkg/events" 6 | ) 7 | 8 | // NotificationEmailFactory generates emails to users about a message 9 | type NotificationEmailFactory interface { 10 | // MessageExpired sends an email when the user's message is expired 11 | MessageExpired(user *entities.User, payload *events.MessageSendExpiredPayload) (*Email, error) 12 | 13 | // MessageFailed sends an email when the user's message is failed 14 | MessageFailed(user *entities.User, payload *events.MessageSendFailedPayload) (*Email, error) 15 | 16 | // DiscordSendFailed sends an email when the user's discord message is failed 17 | DiscordSendFailed(user *entities.User, payload *events.DiscordSendFailedPayload) (*Email, error) 18 | 19 | // WebhookSendFailed sends an email when the user's webhook message is failed 20 | WebhookSendFailed(user *entities.User, payload *events.WebhookSendFailedPayload) (*Email, error) 21 | } 22 | -------------------------------------------------------------------------------- /api/pkg/events/webhook_event_failed_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | // EventTypeWebhookSendFailed is emitted when we can't send a webhook event 9 | const EventTypeWebhookSendFailed = "webhook.send.failed" 10 | 11 | // WebhookSendFailedPayload is the payload of the EventTypeWebhookSendFailed event 12 | type WebhookSendFailedPayload struct { 13 | WebhookID uuid.UUID `json:"webhook_id"` 14 | WebhookURL string `json:"webhook_url"` 15 | Owner string `json:"owner"` 16 | UserID entities.UserID `json:"user_id"` 17 | EventID string `json:"event_id"` 18 | EventType string `json:"event_type"` 19 | EventPayload string `json:"event_payload"` 20 | HTTPResponseStatusCode *int `json:"http_response_status_code"` 21 | ErrorMessage string `json:"error_message"` 22 | } 23 | -------------------------------------------------------------------------------- /api/pkg/requests/billing_usage_history_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // BillingUsageHistory is the payload for fetching the entities.BillingUsage history 10 | type BillingUsageHistory struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Limit string `json:"limit" query:"limit"` 14 | } 15 | 16 | // Sanitize sets defaults to MessageOutstanding 17 | func (input *BillingUsageHistory) Sanitize() BillingUsageHistory { 18 | if strings.TrimSpace(input.Limit) == "" { 19 | input.Limit = "12" 20 | } 21 | input.Skip = strings.TrimSpace(input.Skip) 22 | if input.Skip == "" { 23 | input.Skip = "0" 24 | } 25 | return *input 26 | } 27 | 28 | // ToIndexParams converts BillingUsageHistory to repositories.IndexParams 29 | func (input *BillingUsageHistory) ToIndexParams() repositories.IndexParams { 30 | return repositories.IndexParams{ 31 | Skip: input.getInt(input.Skip), 32 | Limit: input.getInt(input.Limit), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/pkg/requests/user_notification_update_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/services" 5 | ) 6 | 7 | // UserNotificationUpdate is the payload for updating a phone 8 | type UserNotificationUpdate struct { 9 | request 10 | MessageStatusEnabled bool `json:"message_status_enabled" example:"true"` 11 | WebhookEnabled bool `json:"webhook_enabled" example:"true"` 12 | HeartbeatEnabled bool `json:"heartbeat_enabled" example:"true"` 13 | NewsletterEnabled bool `json:"newsletter_enabled" example:"true"` 14 | } 15 | 16 | // ToUserNotificationUpdateParams converts UserNotificationUpdate to services.UserNotificationUpdateParams 17 | func (input *UserNotificationUpdate) ToUserNotificationUpdateParams() *services.UserNotificationUpdateParams { 18 | return &services.UserNotificationUpdateParams{ 19 | MessageStatusEnabled: input.MessageStatusEnabled, 20 | WebhookEnabled: input.WebhookEnabled, 21 | HeartbeatEnabled: input.HeartbeatEnabled, 22 | NewsletterEnabled: input.NewsletterEnabled, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/requests/discord_update_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/NdoleStudio/httpsms/pkg/services" 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // DiscordUpdate is the payload for updating an entities.Webhook 10 | type DiscordUpdate struct { 11 | DiscordStore 12 | DiscordID string `json:"discordID" swaggerignore:"true"` // used internally for validation 13 | } 14 | 15 | // Sanitize sets defaults to WebhookUpdate 16 | func (input *DiscordUpdate) Sanitize() DiscordUpdate { 17 | input.DiscordStore.Sanitize() 18 | return *input 19 | } 20 | 21 | // ToUpdateParams converts DiscordUpdate to services.DiscordUpdateParams 22 | func (input *DiscordUpdate) ToUpdateParams(user entities.AuthContext) *services.DiscordUpdateParams { 23 | return &services.DiscordUpdateParams{ 24 | UserID: user.ID, 25 | Name: input.Name, 26 | ServerID: input.ServerID, 27 | IncomingChannelID: input.IncomingChannelID, 28 | DiscordID: uuid.MustParse(input.DiscordID), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/pkg/requests/webhook_update_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/NdoleStudio/httpsms/pkg/services" 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // WebhookUpdate is the payload for updating an entities.Webhook 10 | type WebhookUpdate struct { 11 | WebhookStore 12 | WebhookID string `json:"webhookID" swaggerignore:"true"` // used internally for validation 13 | } 14 | 15 | // Sanitize sets defaults to WebhookUpdate 16 | func (input *WebhookUpdate) Sanitize() WebhookUpdate { 17 | input.WebhookStore.Sanitize() 18 | return *input 19 | } 20 | 21 | // ToUpdateParams converts WebhookUpdate to services.WebhookUpdateParams 22 | func (input *WebhookUpdate) ToUpdateParams(user entities.AuthContext) *services.WebhookUpdateParams { 23 | return &services.WebhookUpdateParams{ 24 | UserID: user.ID, 25 | WebhookID: uuid.MustParse(input.WebhookID), 26 | SigningKey: input.SigningKey, 27 | URL: input.URL, 28 | PhoneNumbers: input.PhoneNumbers, 29 | Events: input.Events, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/pkg/entities/webhook.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | // Webhook stores the webhooks of a user 11 | type Webhook struct { 12 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 13 | UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 14 | URL string `json:"url" example:"https://example.com"` 15 | SigningKey string `json:"signing_key" example:"DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY"` 16 | PhoneNumbers pq.StringArray `json:"phone_numbers" example:"+18005550199,+18005550100" gorm:"type:text[]" swaggertype:"array,string"` 17 | Events pq.StringArray `json:"events" example:"message.phone.received" gorm:"type:text[]" swaggertype:"array,string"` 18 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 19 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` 20 | } 21 | -------------------------------------------------------------------------------- /api/pkg/events/message_notification_sent_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageNotificationSent is emitted when a new message notification is scheduled 12 | const EventTypeMessageNotificationSent = "message.notification.sent" 13 | 14 | // MessageNotificationSentPayload is the payload of the EventTypeMessageNotificationSent event 15 | type MessageNotificationSentPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | PhoneID uuid.UUID `json:"phone_id"` 19 | ScheduledAt time.Time `json:"scheduled_at"` 20 | FcmMessageID string `json:"fcm_message_id"` 21 | MessageExpirationDuration time.Duration `json:"message_expiration_duration"` 22 | NotificationSentAt time.Time `json:"notification_sent_at"` 23 | NotificationID uuid.UUID `json:"notification_id"` 24 | } 25 | -------------------------------------------------------------------------------- /api/pkg/events/message_api_sent_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageAPISent is emitted when a new message is sent 12 | const EventTypeMessageAPISent = "message.api.sent" 13 | 14 | // MessageAPISentPayload is the payload of the EventTypeMessageSent event 15 | type MessageAPISentPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | RequestID *string `json:"request_id"` 20 | MaxSendAttempts uint `json:"max_send_attempts"` 21 | Contact string `json:"contact"` 22 | ScheduledSendTime *time.Time `json:"scheduled_send_time"` 23 | RequestReceivedAt time.Time `json:"request_received_at"` 24 | Content string `json:"content"` 25 | Encrypted bool `json:"encrypted"` 26 | SIM entities.SIM `json:"sim"` 27 | } 28 | -------------------------------------------------------------------------------- /api/pkg/events/message_notification_scheduled_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageNotificationScheduled is emitted when a new message notification is scheduled 12 | const EventTypeMessageNotificationScheduled = "message.notification.scheduled" 13 | 14 | // MessageNotificationScheduledPayload is the payload of the EventTypeMessageNotificationScheduled event 15 | type MessageNotificationScheduledPayload struct { 16 | MessageID uuid.UUID `json:"id"` 17 | Owner string `json:"owner"` 18 | Contact string `json:"contact"` 19 | Content string `json:"content"` 20 | Encrypted bool `json:"encrypted"` 21 | SIM entities.SIM `json:"sim"` 22 | UserID entities.UserID `json:"user_id"` 23 | PhoneID uuid.UUID `json:"phone_id"` 24 | ScheduledAt time.Time `json:"scheduled_at"` 25 | NotificationID uuid.UUID `json:"notification_id"` 26 | } 27 | -------------------------------------------------------------------------------- /api/pkg/requests/phone_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // PhoneIndex is the payload fetching registered phones 10 | type PhoneIndex struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Query string `json:"query" query:"query"` 14 | Limit string `json:"limit" query:"limit"` 15 | } 16 | 17 | // Sanitize sets defaults to MessageOutstanding 18 | func (input *PhoneIndex) Sanitize() PhoneIndex { 19 | if strings.TrimSpace(input.Limit) == "" { 20 | input.Limit = "10" 21 | } 22 | input.Query = strings.TrimSpace(input.Query) 23 | input.Skip = strings.TrimSpace(input.Skip) 24 | if input.Skip == "" { 25 | input.Skip = "0" 26 | } 27 | return *input 28 | } 29 | 30 | // ToIndexParams converts HeartbeatIndex to repositories.IndexParams 31 | func (input *PhoneIndex) ToIndexParams() repositories.IndexParams { 32 | return repositories.IndexParams{ 33 | Skip: input.getInt(input.Skip), 34 | Query: input.Query, 35 | Limit: input.getInt(input.Limit), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/events/message_send_expired_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // EventTypeMessageSendExpired is emitted when the phone a message expires 12 | const EventTypeMessageSendExpired = "message.send.expired" 13 | 14 | // MessageSendExpiredPayload is the payload of the EventTypeMessageSendExpired event 15 | type MessageSendExpiredPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | Owner string `json:"owner"` 18 | SendAttemptCount uint `json:"send_attempt_count"` 19 | IsFinal bool `json:"is_final"` 20 | RequestID *string `json:"request_id"` 21 | Contact string `json:"contact"` 22 | Encrypted bool `json:"encrypted"` 23 | UserID entities.UserID `json:"user_id"` 24 | Timestamp time.Time `json:"timestamp"` 25 | Content string `json:"content"` 26 | SIM entities.SIM `json:"sim"` 27 | } 28 | -------------------------------------------------------------------------------- /api/pkg/repositories/phone_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // PhoneRepository loads and persists an entities.Phone 12 | type PhoneRepository interface { 13 | // Save Upsert a new entities.Phone 14 | Save(ctx context.Context, phone *entities.Phone) error 15 | 16 | // Index entities.Phone of a user 17 | Index(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.Phone, error) 18 | 19 | // Load a phone by user and phone number 20 | Load(ctx context.Context, userID entities.UserID, phoneNumber string) (*entities.Phone, error) 21 | 22 | // LoadByID a phone by ID 23 | LoadByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID) (*entities.Phone, error) 24 | 25 | // Delete an entities.Phone 26 | Delete(ctx context.Context, userID entities.UserID, phoneID uuid.UUID) error 27 | 28 | // DeleteAllForUser deletes all entities.Phone for a user 29 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 30 | } 31 | -------------------------------------------------------------------------------- /api/pkg/requests/discord_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // DiscordIndex is the payload for fetching entities.Discord of a user 10 | type DiscordIndex struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Query string `json:"query" query:"query"` 14 | Limit string `json:"limit" query:"limit"` 15 | } 16 | 17 | // Sanitize sets defaults to MessageOutstanding 18 | func (input *DiscordIndex) Sanitize() DiscordIndex { 19 | if strings.TrimSpace(input.Limit) == "" { 20 | input.Limit = "1" 21 | } 22 | input.Query = strings.TrimSpace(input.Query) 23 | input.Skip = strings.TrimSpace(input.Skip) 24 | if input.Skip == "" { 25 | input.Skip = "0" 26 | } 27 | return *input 28 | } 29 | 30 | // ToIndexParams converts HeartbeatIndex to repositories.IndexParams 31 | func (input *DiscordIndex) ToIndexParams() repositories.IndexParams { 32 | return repositories.IndexParams{ 33 | Skip: input.getInt(input.Skip), 34 | Query: input.Query, 35 | Limit: input.getInt(input.Limit), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/requests/webhook_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // WebhookIndex is the payload for fetching entities.Webhook of a user 10 | type WebhookIndex struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Query string `json:"query" query:"query"` 14 | Limit string `json:"limit" query:"limit"` 15 | } 16 | 17 | // Sanitize sets defaults to MessageOutstanding 18 | func (input *WebhookIndex) Sanitize() WebhookIndex { 19 | if strings.TrimSpace(input.Limit) == "" { 20 | input.Limit = "1" 21 | } 22 | input.Query = strings.TrimSpace(input.Query) 23 | input.Skip = strings.TrimSpace(input.Skip) 24 | if input.Skip == "" { 25 | input.Skip = "0" 26 | } 27 | return *input 28 | } 29 | 30 | // ToIndexParams converts HeartbeatIndex to repositories.IndexParams 31 | func (input *WebhookIndex) ToIndexParams() repositories.IndexParams { 32 | return repositories.IndexParams{ 33 | Skip: input.getInt(input.Skip), 34 | Query: input.Query, 35 | Limit: input.getInt(input.Limit), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/repositories/billing_usage_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/entities" 8 | ) 9 | 10 | // BillingUsageRepository loads and persists an entities.BillingUsage 11 | type BillingUsageRepository interface { 12 | // RegisterSentMessage registers a message as sent 13 | RegisterSentMessage(ctx context.Context, timestamp time.Time, user entities.UserID) error 14 | 15 | // RegisterReceivedMessage registers a message as received 16 | RegisterReceivedMessage(ctx context.Context, timestamp time.Time, user entities.UserID) error 17 | 18 | // GetCurrent returns the current billing usage by entities.UserID 19 | GetCurrent(ctx context.Context, userID entities.UserID) (*entities.BillingUsage, error) 20 | 21 | // GetHistory returns past billing usage by entities.UserID 22 | GetHistory(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.BillingUsage, error) 23 | 24 | // DeleteForUser deletes all billing usage for an entities.UserID 25 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 26 | } 27 | -------------------------------------------------------------------------------- /api/pkg/requests/phone_api_key_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // PhoneAPIKeyIndex is the payload for fetching entities.PhoneAPIKey of a user 10 | type PhoneAPIKeyIndex struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Query string `json:"query" query:"query"` 14 | Limit string `json:"limit" query:"limit"` 15 | } 16 | 17 | // Sanitize sets defaults to MessageOutstanding 18 | func (input *PhoneAPIKeyIndex) Sanitize() PhoneAPIKeyIndex { 19 | if strings.TrimSpace(input.Limit) == "" { 20 | input.Limit = "1" 21 | } 22 | input.Query = strings.TrimSpace(input.Query) 23 | input.Skip = strings.TrimSpace(input.Skip) 24 | if input.Skip == "" { 25 | input.Skip = "0" 26 | } 27 | return *input 28 | } 29 | 30 | // ToIndexParams converts HeartbeatIndex to repositories.IndexParams 31 | func (input *PhoneAPIKeyIndex) ToIndexParams() repositories.IndexParams { 32 | return repositories.IndexParams{ 33 | Skip: input.getInt(input.Skip), 34 | Query: input.Query, 35 | Limit: input.getInt(input.Limit), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/components/BlogInfo.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 39 | -------------------------------------------------------------------------------- /api/pkg/discord/application_service.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // ApplicationService is the API client for the interacting with commands 11 | type ApplicationService service 12 | 13 | // CreateCommand creates a new guild command 14 | // 15 | // API Docs: https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command 16 | func (service *ApplicationService) CreateCommand(ctx context.Context, serverID string, params *CommandCreateRequest) (*CommandCreateResponse, *Response, error) { 17 | url := fmt.Sprintf("/applications/%s/guilds/%s/commands", service.client.applicationID, serverID) 18 | request, err := service.client.newRequest(ctx, http.MethodPost, url, params) 19 | if err != nil { 20 | return nil, nil, err 21 | } 22 | 23 | response, err := service.client.do(request) 24 | if err != nil { 25 | return nil, response, err 26 | } 27 | 28 | message := new(CommandCreateResponse) 29 | if err = json.Unmarshal(*response.Body, message); err != nil { 30 | return nil, response, err 31 | } 32 | 33 | return message, response, nil 34 | } 35 | -------------------------------------------------------------------------------- /api/pkg/repositories/webhook_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // WebhookRepository loads and persists an entities.User 12 | type WebhookRepository interface { 13 | // Save Upsert a new entities.Webhook 14 | Save(ctx context.Context, phone *entities.Webhook) error 15 | 16 | // Index entities.Webhook by entities.UserID 17 | Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.Webhook, error) 18 | 19 | // LoadByEvent loads webhooks for a user and event. 20 | LoadByEvent(ctx context.Context, userID entities.UserID, event string, phoneNumber string) ([]*entities.Webhook, error) 21 | 22 | // Load loads a webhook by ID. 23 | Load(ctx context.Context, userID entities.UserID, webhookID uuid.UUID) (*entities.Webhook, error) 24 | 25 | // Delete an entities.Webhook 26 | Delete(ctx context.Context, userID entities.UserID, webhookID uuid.UUID) error 27 | 28 | // DeleteAllForUser deletes all entities.Webhook for a user 29 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 30 | } 31 | -------------------------------------------------------------------------------- /api/pkg/telemetry/logger.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "go.opentelemetry.io/otel/trace" 5 | ) 6 | 7 | // Logger is an interface for creating customer logger implementations 8 | type Logger interface { 9 | // Error logs an error 10 | Error(err error) 11 | 12 | // WithService creates a new structured logger instance with a service name 13 | WithService(string) Logger 14 | 15 | // WithString creates a new structured logger instance with a string 16 | WithString(key string, value string) Logger 17 | 18 | // WithSpan creates a new structured logger instance for a spanContext 19 | WithSpan(span trace.SpanContext) Logger 20 | 21 | // Trace logs a new message with trace level. 22 | Trace(value string) 23 | 24 | // Info logs a new message with information level. 25 | Info(value string) 26 | 27 | // Warn logs a new message with warning level. 28 | Warn(err error) 29 | 30 | // Debug logs a new message with debug level. 31 | Debug(value string) 32 | 33 | // Fatal logs a new message with fatal level. 34 | Fatal(err error) 35 | 36 | // Printf makes the logger compatible with retryablehttp.Logger 37 | Printf(string, ...interface{}) 38 | } 39 | -------------------------------------------------------------------------------- /api/pkg/entities/phone_notification.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | const ( 10 | // PhoneNotificationStatusPending is the status when a notification is scheduled to be sent 11 | PhoneNotificationStatusPending = "pending" 12 | // PhoneNotificationStatusSent is the status when a notification has been sent 13 | PhoneNotificationStatusSent = "sent" 14 | // PhoneNotificationStatusFailed is the status when a notification could not be sent. 15 | PhoneNotificationStatusFailed = "failed" 16 | ) 17 | 18 | // PhoneNotificationStatus is the status of a phone notification 19 | type PhoneNotificationStatus string 20 | 21 | // PhoneNotification represents an FCM notification to a mobile phone 22 | type PhoneNotification struct { 23 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"` 24 | MessageID uuid.UUID `json:"message_id"` 25 | UserID UserID `json:"user_id"` 26 | PhoneID uuid.UUID `json:"phone_id"` 27 | Status string `json:"status"` 28 | ScheduledAt time.Time `json:"scheduled_at"` 29 | CreatedAt time.Time `json:"created_at"` 30 | UpdatedAt time.Time `json:"updated_at"` 31 | } 32 | -------------------------------------------------------------------------------- /api/pkg/requests/message_outstanding_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/entities" 8 | "github.com/NdoleStudio/httpsms/pkg/services" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // MessageOutstanding is the payload fetching outstanding entities.Message 13 | type MessageOutstanding struct { 14 | request 15 | MessageID string `json:"message_id" query:"message_id"` 16 | } 17 | 18 | // Sanitize sets defaults to MessageOutstanding 19 | func (input *MessageOutstanding) Sanitize() MessageOutstanding { 20 | input.MessageID = strings.TrimSpace(input.MessageID) 21 | return *input 22 | } 23 | 24 | // ToGetOutstandingParams converts MessageOutstanding into services.MessageGetOutstandingParams 25 | func (input *MessageOutstanding) ToGetOutstandingParams(source string, authCtx entities.AuthContext, timestamp time.Time) services.MessageGetOutstandingParams { 26 | return services.MessageGetOutstandingParams{ 27 | Source: source, 28 | PhoneNumbers: authCtx.PhoneNumbers, 29 | UserID: authCtx.ID, 30 | MessageID: uuid.MustParse(input.MessageID), 31 | Timestamp: timestamp, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /web/components/LoadingDashboard.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /api/pkg/requests/discord_store_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/NdoleStudio/httpsms/pkg/services" 8 | ) 9 | 10 | // DiscordStore is the payload for creating a new entities.Discord 11 | type DiscordStore struct { 12 | request 13 | Name string `json:"name"` 14 | ServerID string `json:"server_id"` 15 | IncomingChannelID string `json:"incoming_channel_id"` 16 | } 17 | 18 | // Sanitize sets defaults to DiscordStore 19 | func (input *DiscordStore) Sanitize() DiscordStore { 20 | input.Name = strings.TrimSpace(input.Name) 21 | input.ServerID = strings.TrimSpace(input.ServerID) 22 | input.IncomingChannelID = strings.TrimSpace(input.IncomingChannelID) 23 | return *input 24 | } 25 | 26 | // ToStoreParams converts DiscordStore to services.WebhookStoreParams 27 | func (input *DiscordStore) ToStoreParams(user entities.AuthContext) *services.DiscordStoreParams { 28 | return &services.DiscordStoreParams{ 29 | UserID: user.ID, 30 | Name: input.Name, 31 | ServerID: input.ServerID, 32 | IncomingChannelID: input.IncomingChannelID, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/pkg/middlewares/authenticated_middlesare.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/NdoleStudio/httpsms/pkg/entities" 5 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | const ( 10 | authHeaderBearer = "Authorization" 11 | authHeaderAPIKey = "x-api-key" 12 | bearerScheme = "Bearer" 13 | ) 14 | 15 | const ( 16 | // ContextKeyAuthUserID is the context key used to store the ID of an authenticated user 17 | ContextKeyAuthUserID = "auth.user.id" 18 | ) 19 | 20 | // Authenticated checks if the request is authenticated 21 | func Authenticated(tracer telemetry.Tracer) fiber.Handler { 22 | return func(c *fiber.Ctx) error { 23 | _, span := tracer.StartFromFiberCtx(c, "middlewares.Authenticated") 24 | defer span.End() 25 | 26 | if tokenUser, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthContext); !ok || tokenUser.IsNoop() { 27 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ 28 | "status": "error", 29 | "message": "You are not authorized to carry out this request.", 30 | "data": "Make sure your API key is set in the [x-api-key] header in the request", 31 | }) 32 | } 33 | 34 | return c.Next() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/baseline_settings_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /api/pkg/validators/user_handler_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/requests" 9 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 10 | "github.com/thedevsaddam/govalidator" 11 | ) 12 | 13 | // UserHandlerValidator validates models used in handlers.UserHandler 14 | type UserHandlerValidator struct { 15 | validator 16 | logger telemetry.Logger 17 | tracer telemetry.Tracer 18 | } 19 | 20 | // NewUserHandlerValidator creates a new handlers.UserHandler validator 21 | func NewUserHandlerValidator( 22 | logger telemetry.Logger, 23 | tracer telemetry.Tracer, 24 | ) (v *UserHandlerValidator) { 25 | return &UserHandlerValidator{ 26 | logger: logger.WithService(fmt.Sprintf("%T", v)), 27 | tracer: tracer, 28 | } 29 | } 30 | 31 | // ValidateUpdate validates requests.UserUpdate 32 | func (validator *UserHandlerValidator) ValidateUpdate(_ context.Context, request requests.UserUpdate) url.Values { 33 | v := govalidator.New(govalidator.Options{ 34 | Data: &request, 35 | Rules: govalidator.MapData{ 36 | "active_phone_id": []string{ 37 | "uuid", 38 | }, 39 | }, 40 | }) 41 | 42 | return v.ValidateStruct() 43 | } 44 | -------------------------------------------------------------------------------- /api/pkg/requests/heartbeat_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/repositories" 7 | ) 8 | 9 | // HeartbeatIndex is the payload for fetching entities.Heartbeat of a phone number 10 | type HeartbeatIndex struct { 11 | request 12 | Skip string `json:"skip" query:"skip"` 13 | Owner string `json:"owner" query:"owner"` 14 | Query string `json:"query" query:"query"` 15 | Limit string `json:"limit" query:"limit"` 16 | } 17 | 18 | // Sanitize sets defaults to MessageOutstanding 19 | func (input *HeartbeatIndex) Sanitize() HeartbeatIndex { 20 | if strings.TrimSpace(input.Limit) == "" { 21 | input.Limit = "1" 22 | } 23 | input.Query = strings.TrimSpace(input.Query) 24 | input.Owner = input.sanitizeAddress(input.Owner) 25 | input.Skip = strings.TrimSpace(input.Skip) 26 | if input.Skip == "" { 27 | input.Skip = "0" 28 | } 29 | return *input 30 | } 31 | 32 | // ToIndexParams converts HeartbeatIndex to repositories.IndexParams 33 | func (input *HeartbeatIndex) ToIndexParams() repositories.IndexParams { 34 | return repositories.IndexParams{ 35 | Skip: input.getInt(input.Skip), 36 | Query: input.Query, 37 | Limit: input.getInt(input.Limit), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /web/plugins/errors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import Bag from '@/plugins/bag' 3 | import capitalize from '@/plugins/capitalize' 4 | 5 | export class ErrorMessages extends Bag {} 6 | 7 | const sanitize = (key: string, values: Array): Array => { 8 | return values.map((value: string) => { 9 | return capitalize( 10 | value 11 | .split(key) 12 | .join(key.replace('_', ' ')) 13 | .split('_') 14 | .join(' ') 15 | .split('-') 16 | .join(' ') 17 | .split(' char') 18 | .join(' character') 19 | .split(' field ') 20 | .join(' '), 21 | ) 22 | }) 23 | } 24 | 25 | export const getErrorMessages = (error: AxiosError): ErrorMessages => { 26 | const errors = new ErrorMessages() 27 | if ( 28 | error === null || 29 | typeof (error.response?.data as any)?.data !== 'object' || 30 | (error.response?.data as any)?.data === null || 31 | error.response?.status !== 422 32 | ) { 33 | return errors 34 | } 35 | 36 | Object.keys((error.response?.data as any).data).forEach((key: string) => { 37 | errors.addMany(key, sanitize(key, (error.response?.data as any).data[key])) 38 | }) 39 | 40 | return errors 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | image: postgres:alpine 6 | environment: 7 | POSTGRES_DB: httpsms 8 | POSTGRES_PASSWORD: dbpassword 9 | POSTGRES_USER: dbusername 10 | volumes: 11 | - postgres:/var/lib/postgresql/data 12 | ports: 13 | - "5435:5432" 14 | restart: on-failure 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready", "-U", "dbusername", "-d", "httpsms"] 17 | interval: 30s 18 | timeout: 60s 19 | retries: 5 20 | start_period: 5s 21 | 22 | redis: 23 | image: redis:latest 24 | command: redis-server 25 | volumes: 26 | - redis:/var/lib/redis 27 | ports: 28 | - "6379:6379" 29 | restart: on-failure 30 | 31 | api: 32 | build: 33 | context: ./api 34 | ports: 35 | - "8000:8000" 36 | depends_on: 37 | postgres: 38 | condition: service_healthy 39 | redis: 40 | condition: service_started 41 | env_file: 42 | - ./api/.env 43 | 44 | web: 45 | build: 46 | context: ./web 47 | ports: 48 | - "3000:3000" 49 | depends_on: 50 | api: 51 | condition: service_started 52 | 53 | volumes: 54 | redis: 55 | postgres: 56 | -------------------------------------------------------------------------------- /api/pkg/entities/heartbeat_monitor.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // HeartbeatMonitor is used to monitor heartbeats of a phone 10 | type HeartbeatMonitor struct { 11 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 12 | PhoneID uuid.UUID `json:"phone_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 13 | UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 14 | QueueID string `json:"queue_id" example:"0360259236613675274"` 15 | Owner string `json:"owner" example:"+18005550199"` 16 | PhoneOnline bool `json:"phone_online" example:"true" default:"true"` 17 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 18 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` 19 | } 20 | 21 | // RequiresCheck returns true if the heartbeat monitor requires a check 22 | func (h *HeartbeatMonitor) RequiresCheck() bool { 23 | return h.UpdatedAt.Add(2 * time.Hour).Before(time.Now()) 24 | } 25 | 26 | // PhoneIsOffline returns true if the phone is offline 27 | func (h *HeartbeatMonitor) PhoneIsOffline() bool { 28 | return !h.PhoneOnline 29 | } 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Fetch and update latest `npm` packages 4 | - package-ecosystem: npm 5 | directory: "/web" 6 | schedule: 7 | interval: monthly 8 | time: "00:00" 9 | open-pull-requests-limit: 10 10 | reviewers: 11 | - AchoArnold 12 | assignees: 13 | - AchoArnold 14 | commit-message: 15 | prefix: fix 16 | prefix-development: chore 17 | include: scope 18 | # Fetch and update latest `github-actions` pkgs 19 | - package-ecosystem: github-actions 20 | directory: "/" 21 | schedule: 22 | interval: monthly 23 | time: "00:00" 24 | open-pull-requests-limit: 10 25 | reviewers: 26 | - AchoArnold 27 | assignees: 28 | - AchoArnold 29 | commit-message: 30 | prefix: fix 31 | prefix-development: chore 32 | include: scope 33 | # Fetch and update latest `go` packages 34 | - package-ecosystem: gomod 35 | directory: "/api" 36 | schedule: 37 | interval: monthly 38 | time: "00:00" 39 | open-pull-requests-limit: 10 40 | reviewers: 41 | - AchoArnold 42 | assignees: 43 | - AchoArnold 44 | commit-message: 45 | prefix: fix 46 | prefix-development: chore 47 | include: scope 48 | -------------------------------------------------------------------------------- /api/pkg/requests/user_update_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/NdoleStudio/httpsms/pkg/services" 10 | ) 11 | 12 | // UserUpdate is the payload for updating a phone 13 | type UserUpdate struct { 14 | request 15 | Timezone string `json:"timezone" example:"Europe/Helsinki"` 16 | ActivePhoneID string `json:"active_phone_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 17 | } 18 | 19 | // Sanitize sets defaults to MessageOutstanding 20 | func (input *UserUpdate) Sanitize() UserUpdate { 21 | input.ActivePhoneID = strings.TrimSpace(input.ActivePhoneID) 22 | input.Timezone = strings.TrimSpace(input.Timezone) 23 | return *input 24 | } 25 | 26 | // ToUpdateParams converts UserUpdate to services.UserUpdateParams 27 | func (input *UserUpdate) ToUpdateParams() services.UserUpdateParams { 28 | location, err := time.LoadLocation(input.Timezone) 29 | if err != nil { 30 | location = time.UTC 31 | } 32 | 33 | var activePhoneID *uuid.UUID 34 | if input.ActivePhoneID != "" { 35 | val := uuid.MustParse(input.ActivePhoneID) 36 | activePhoneID = &val 37 | } 38 | 39 | return services.UserUpdateParams{ 40 | ActivePhoneID: activePhoneID, 41 | Timezone: location, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/pkg/middlewares/http_request_logger_middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/palantir/stacktrace" 9 | ) 10 | 11 | const ( 12 | clientVersionHeader = "X-Client-Version" 13 | ) 14 | 15 | // HTTPRequestLogger adds a trace for an HTTP request 16 | func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.Handler { 17 | return func(c *fiber.Ctx) error { 18 | _, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger) 19 | defer span.End() 20 | 21 | ctxLogger.WithString("http.method", c.Method()). 22 | WithString("http.path", c.Path()). 23 | WithString("client.version", c.Get(clientVersionHeader)). 24 | Trace(fmt.Sprintf("%s %s", c.Method(), c.OriginalURL())) 25 | 26 | response := c.Next() 27 | 28 | statusCode := c.Response().StatusCode() 29 | span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) 30 | if statusCode >= 300 && len(c.Request().Body()) > 0 { 31 | ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("http.status [%d], body [%s]", statusCode, string(c.Request().Body())))) 32 | } 33 | 34 | return response 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/pkg/events/message_api_deleted_event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // MessageAPIDeleted is emitted when a new message is deleted 12 | const MessageAPIDeleted = "message.api.deleted" 13 | 14 | // MessageAPIDeletedPayload is the payload of the MessageAPIDeleted event 15 | type MessageAPIDeletedPayload struct { 16 | MessageID uuid.UUID `json:"message_id"` 17 | UserID entities.UserID `json:"user_id"` 18 | Owner string `json:"owner"` 19 | RequestID *string `json:"request_id"` 20 | Contact string `json:"contact"` 21 | Timestamp time.Time `json:"timestamp"` 22 | Content string `json:"content"` 23 | Encrypted bool `json:"encrypted"` 24 | PreviousMessageID *uuid.UUID `json:"previous_message_id"` 25 | PreviousMessageStatus *entities.MessageStatus `json:"previous_message_status"` 26 | PreviousMessageContent *string `json:"previous_message_content"` 27 | SIM entities.SIM `json:"sim"` 28 | } 29 | -------------------------------------------------------------------------------- /api/pkg/cache/memory_cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | "github.com/palantir/stacktrace" 10 | ttlCache "github.com/patrickmn/go-cache" 11 | ) 12 | 13 | // memoryCache is the Cache implementation in memory 14 | type memoryCache struct { 15 | tracer telemetry.Tracer 16 | store *ttlCache.Cache 17 | } 18 | 19 | // NewMemoryCache creates a new instance of memoryCache 20 | func NewMemoryCache(tracer telemetry.Tracer, store *ttlCache.Cache) Cache { 21 | return &memoryCache{ 22 | tracer: tracer, 23 | store: store, 24 | } 25 | } 26 | 27 | // Get an item from the redis cache 28 | func (cache *memoryCache) Get(ctx context.Context, key string) (value string, err error) { 29 | ctx, span := cache.tracer.Start(ctx) 30 | defer span.End() 31 | 32 | response, ok := cache.store.Get(key) 33 | if !ok { 34 | return "", stacktrace.NewError(fmt.Sprintf("no item found in cache with key [%s]", key)) 35 | } 36 | 37 | return response.(string), nil 38 | } 39 | 40 | // Set an item in the redis cache 41 | func (cache *memoryCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error { 42 | ctx, span := cache.tracer.Start(ctx) 43 | defer span.End() 44 | 45 | cache.store.Set(key, value, ttl) 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /api/pkg/repositories/discord_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // DiscordRepository loads and persists an entities.Discord 12 | type DiscordRepository interface { 13 | // Save Upsert a new entities.Discord 14 | Save(ctx context.Context, phone *entities.Discord) error 15 | 16 | // Index entities.Discord by entities.UserID 17 | Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.Discord, error) 18 | 19 | // FetchHavingIncomingChannel loads Discords for a user that has an incoming channel ID set. 20 | FetchHavingIncomingChannel(ctx context.Context, userID entities.UserID) ([]*entities.Discord, error) 21 | 22 | // Load loads a Discord by ID. 23 | Load(ctx context.Context, userID entities.UserID, DiscordID uuid.UUID) (*entities.Discord, error) 24 | 25 | // FindByServerID loads a Discord by the serverID. 26 | FindByServerID(ctx context.Context, serverID string) (*entities.Discord, error) 27 | 28 | // Delete an entities.Discord 29 | Delete(ctx context.Context, userID entities.UserID, DiscordID uuid.UUID) error 30 | 31 | // DeleteAllForUser deletes all entities.Discord for a user 32 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 33 | } 34 | -------------------------------------------------------------------------------- /api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/NdoleStudio/httpsms/docs" 9 | "github.com/NdoleStudio/httpsms/pkg/di" 10 | ) 11 | 12 | // Version is injected at runtime 13 | var Version string 14 | 15 | // @title httpSMS API Reference 16 | // @version 1.0 17 | // @description Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption. 18 | // 19 | // @contact.name support@httpsms.com 20 | // @contact.email support@httpsms.com 21 | // 22 | // @license.name AGPL-3.0 23 | // @license.url https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE 24 | // 25 | // @host api.httpsms.com 26 | // @schemes https 27 | // @BasePath /v1 28 | // 29 | // @securitydefinitions.apikey ApiKeyAuth 30 | // @in header 31 | // @name x-api-Key 32 | func main() { 33 | if len(os.Args) == 1 { 34 | di.LoadEnv() 35 | } 36 | 37 | if host := strings.TrimSpace(os.Getenv("SWAGGER_HOST")); len(host) > 0 { 38 | docs.SwaggerInfo.Host = host 39 | } 40 | if len(Version) > 0 { 41 | docs.SwaggerInfo.Version = Version 42 | } 43 | 44 | container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), Version) 45 | container.Logger().Info(container.App().Listen(fmt.Sprintf("%s:%s", os.Getenv("APP_HOST"), os.Getenv("APP_PORT"))).Error()) 46 | } 47 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/receivers/BootReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms.receivers 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import com.httpsms.Constants 7 | import com.httpsms.Settings 8 | import com.httpsms.services.StickyNotificationService 9 | import timber.log.Timber 10 | 11 | 12 | class BootReceiver : BroadcastReceiver() { 13 | override fun onReceive(context: Context, intent: Intent) { 14 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) { 15 | startStickyNotification(context) 16 | } else { 17 | Timber.e("invalid intent [${intent.action}]") 18 | } 19 | } 20 | private fun startStickyNotification(context: Context) { 21 | if(!Settings.getActiveStatus(context, Constants.SIM1) && !Settings.getActiveStatus(context, Constants.SIM2)) { 22 | Timber.d("active status is false, not starting foreground service") 23 | return 24 | } 25 | 26 | Timber.d("starting foreground service") 27 | val notificationIntent = Intent(context, StickyNotificationService::class.java) 28 | val service = context.startForegroundService(notificationIntent) 29 | Timber.d("foreground service started [${service?.className}]") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/components/FixedHeader.vue: -------------------------------------------------------------------------------- 1 | 34 | 39 | -------------------------------------------------------------------------------- /api/pkg/entities/billing_usage.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // BillingUsage tracks the billing usage of an account 10 | type BillingUsage struct { 11 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 12 | UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 13 | SentMessages uint `json:"sent_messages" example:"321"` 14 | ReceivedMessages uint `json:"received_messages" example:"465"` 15 | TotalCost uint `json:"total_cost" example:"0"` 16 | StartTimestamp time.Time `json:"start_timestamp" example:"2022-01-01T00:00:00+00:00"` 17 | EndTimestamp time.Time `json:"end_timestamp" example:"2022-01-31T23:59:59+00:00"` 18 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 19 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` 20 | } 21 | 22 | // TotalMessages returns the sum of sent and received messages 23 | func (usage *BillingUsage) TotalMessages() uint { 24 | return usage.SentMessages + usage.ReceivedMessages 25 | } 26 | 27 | // IsEntitled checks if a user can send `count` messages 28 | func (usage *BillingUsage) IsEntitled(count, limit uint) bool { 29 | return (usage.TotalMessages() + count) < limit 30 | } 31 | -------------------------------------------------------------------------------- /api/pkg/entities/phone_api_key.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | // PhoneAPIKey represents the API key for a phone 11 | type PhoneAPIKey struct { 12 | ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` 13 | Name string `json:"name" example:"Business Phone Key"` 14 | UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` 15 | UserEmail string `json:"user_email" example:"user@gmail.com"` 16 | PhoneNumbers pq.StringArray `json:"phone_numbers" example:"+18005550199,+18005550100" gorm:"type:text[]" swaggertype:"array,string"` 17 | PhoneIDs pq.StringArray `json:"phone_ids" example:"32343a19-da5e-4b1b-a767-3298a73703cb,32343a19-da5e-4b1b-a767-3298a73703cc" gorm:"type:text[]" swaggertype:"array,string"` 18 | APIKey string `json:"api_key" gorm:"uniqueIndex:idx_phone_api_key__api_key;NOT NULL" example:"pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx"` 19 | CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` 20 | UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:02.302718+03:00"` 21 | } 22 | 23 | // TableName overrides the table name used by PhoneAPIKey 24 | func (PhoneAPIKey) TableName() string { 25 | return "phone_api_keys" 26 | } 27 | -------------------------------------------------------------------------------- /api/pkg/repositories/heartbeat_monitor_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // HeartbeatMonitorRepository loads and persists an entities.HeartbeatMonitor 12 | type HeartbeatMonitorRepository interface { 13 | // Store a new entities.HeartbeatMonitor 14 | Store(ctx context.Context, heartbeat *entities.HeartbeatMonitor) error 15 | 16 | // Load a phone by user and phone number 17 | Load(ctx context.Context, userID entities.UserID, phoneNumber string) (*entities.HeartbeatMonitor, error) 18 | 19 | // Exists checks if a heartbeat monitor exists for a phone number 20 | Exists(ctx context.Context, userID entities.UserID, monitorID uuid.UUID) (bool, error) 21 | 22 | // UpdateQueueID updates the queueID of a monitor 23 | UpdateQueueID(ctx context.Context, monitorID uuid.UUID, queueID string) error 24 | 25 | // Delete an entities.HeartbeatMonitor 26 | Delete(ctx context.Context, userID entities.UserID, phoneNumber string) error 27 | 28 | // UpdatePhoneOnline updates the phone online status of a monitor 29 | UpdatePhoneOnline(ctx context.Context, userID entities.UserID, monitorID uuid.UUID, online bool) error 30 | 31 | // DeleteAllForUser deletes all entities.HeartbeatMonitor for a user 32 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 33 | } 34 | -------------------------------------------------------------------------------- /api/pkg/validators/billing_handler_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/requests" 9 | 10 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 11 | "github.com/thedevsaddam/govalidator" 12 | ) 13 | 14 | // BillingHandlerValidator validates models used in handlers.BillingHandler 15 | type BillingHandlerValidator struct { 16 | validator 17 | logger telemetry.Logger 18 | tracer telemetry.Tracer 19 | } 20 | 21 | // NewBillingHandlerValidator creates a new handlers.BillingHandler validator 22 | func NewBillingHandlerValidator( 23 | logger telemetry.Logger, 24 | tracer telemetry.Tracer, 25 | ) (v *BillingHandlerValidator) { 26 | return &BillingHandlerValidator{ 27 | logger: logger.WithService(fmt.Sprintf("%T", v)), 28 | tracer: tracer, 29 | } 30 | } 31 | 32 | // ValidateHistory validates the requests.BillingUsageHistory request 33 | func (validator *BillingHandlerValidator) ValidateHistory(_ context.Context, request requests.BillingUsageHistory) url.Values { 34 | v := govalidator.New(govalidator.Options{ 35 | Data: &request, 36 | Rules: govalidator.MapData{ 37 | "limit": []string{ 38 | "required", 39 | "numeric", 40 | "min:1", 41 | "max:100", 42 | }, 43 | "skip": []string{ 44 | "required", 45 | "numeric", 46 | "min:0", 47 | }, 48 | }, 49 | }) 50 | return v.ValidateStruct() 51 | } 52 | -------------------------------------------------------------------------------- /api/pkg/discord/client_option.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // Option is options for constructing a client 9 | type Option interface { 10 | apply(config *clientConfig) 11 | } 12 | 13 | type clientOptionFunc func(config *clientConfig) 14 | 15 | func (fn clientOptionFunc) apply(config *clientConfig) { 16 | fn(config) 17 | } 18 | 19 | // WithHTTPClient sets the underlying HTTP client used for API requests. 20 | // By default, http.DefaultClient is used. 21 | func WithHTTPClient(httpClient *http.Client) Option { 22 | return clientOptionFunc(func(config *clientConfig) { 23 | if httpClient != nil { 24 | config.httpClient = httpClient 25 | } 26 | }) 27 | } 28 | 29 | // WithBaseURL set's the base url for the discord API 30 | func WithBaseURL(baseURL string) Option { 31 | return clientOptionFunc(func(config *clientConfig) { 32 | if baseURL != "" { 33 | config.baseURL = strings.TrimRight(baseURL, "/") 34 | } 35 | }) 36 | } 37 | 38 | // WithApplicationID sets the discord bot application ID 39 | func WithApplicationID(applicationID string) Option { 40 | return clientOptionFunc(func(config *clientConfig) { 41 | config.applicationID = applicationID 42 | }) 43 | } 44 | 45 | // WithBotToken sets the discord bot token 46 | func WithBotToken(botToken string) Option { 47 | return clientOptionFunc(func(config *clientConfig) { 48 | config.botToken = botToken 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /api/pkg/requests/webhook_store_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/NdoleStudio/httpsms/pkg/services" 8 | ) 9 | 10 | // WebhookStore is the payload for creating a new entities.Webhook 11 | type WebhookStore struct { 12 | request 13 | SigningKey string `json:"signing_key"` 14 | URL string `json:"url"` 15 | PhoneNumbers []string `json:"phone_numbers" example:"+18005550100,+18005550100"` 16 | Events []string `json:"events"` 17 | } 18 | 19 | // Sanitize sets defaults to WebhookStore 20 | func (input *WebhookStore) Sanitize() WebhookStore { 21 | input.URL = input.sanitizeURL(input.URL) 22 | input.SigningKey = strings.TrimSpace(input.SigningKey) 23 | input.Events = input.removeStringDuplicates(input.Events) 24 | 25 | var phoneNumbers []string 26 | for _, address := range input.PhoneNumbers { 27 | phoneNumbers = append(phoneNumbers, input.sanitizeAddress(address)) 28 | } 29 | 30 | return *input 31 | } 32 | 33 | // ToStoreParams converts WebhookStore to services.WebhookStoreParams 34 | func (input *WebhookStore) ToStoreParams(user entities.AuthContext) *services.WebhookStoreParams { 35 | return &services.WebhookStoreParams{ 36 | UserID: user.ID, 37 | SigningKey: input.SigningKey, 38 | URL: input.URL, 39 | PhoneNumbers: input.PhoneNumbers, 40 | Events: input.Events, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/components/NuxtLogo.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /api/pkg/validators/lemonsqueezy_handler_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | lemonsqueezy "github.com/NdoleStudio/lemonsqueezy-go" 10 | ) 11 | 12 | // LemonsqueezyHandlerValidator validates models used in handlers.LemonsqueezyHandler 13 | type LemonsqueezyHandlerValidator struct { 14 | logger telemetry.Logger 15 | tracer telemetry.Tracer 16 | client *lemonsqueezy.Client 17 | } 18 | 19 | // NewLemonsqueezyHandlerValidator creates a new handlers.LemonsqueezyHandler validator 20 | func NewLemonsqueezyHandlerValidator( 21 | logger telemetry.Logger, 22 | tracer telemetry.Tracer, 23 | client *lemonsqueezy.Client, 24 | ) (v *LemonsqueezyHandlerValidator) { 25 | return &LemonsqueezyHandlerValidator{ 26 | logger: logger.WithService(fmt.Sprintf("%T", v)), 27 | tracer: tracer, 28 | client: client, 29 | } 30 | } 31 | 32 | // ValidateEvent checks that an event is coming from lemonsqueezy 33 | func (validator *LemonsqueezyHandlerValidator) ValidateEvent(ctx context.Context, signature string, request []byte) url.Values { 34 | _, span := validator.tracer.Start(ctx) 35 | defer span.End() 36 | 37 | isValid := validator.client.Webhooks.Verify(ctx, signature, request) 38 | if !isValid { 39 | return url.Values{ 40 | "body": []string{ 41 | "The signature is not valid", 42 | }, 43 | } 44 | } 45 | return url.Values{} 46 | } 47 | -------------------------------------------------------------------------------- /api/pkg/middlewares/phone_api_key_auth_middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/repositories" 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/palantir/stacktrace" 11 | ) 12 | 13 | // PhoneAPIKeyAuth authenticates a user from the X-API-Key header 14 | func PhoneAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, repository repositories.PhoneAPIKeyRepository) fiber.Handler { 15 | logger = logger.WithService("middlewares.APIKeyAuth") 16 | 17 | return func(c *fiber.Ctx) error { 18 | ctx, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger, "middlewares.APIKeyAuth") 19 | defer span.End() 20 | 21 | apiKey := c.Get(authHeaderAPIKey) 22 | if len(apiKey) == 0 || apiKey == "undefined" || !strings.HasPrefix(apiKey, "pk_") { 23 | span.AddEvent(fmt.Sprintf("the request header has no [%s] header for the phone key", authHeaderAPIKey)) 24 | return c.Next() 25 | } 26 | 27 | authUser, err := repository.LoadAuthContext(ctx, apiKey) 28 | if err != nil { 29 | ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load user with phone api key [%s]", apiKey))) 30 | return c.Next() 31 | } 32 | 33 | c.Locals(ContextKeyAuthUserID, authUser) 34 | ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) 35 | return c.Next() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/requests/message_call_missed_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/nyaruka/phonenumbers" 9 | 10 | "github.com/NdoleStudio/httpsms/pkg/services" 11 | ) 12 | 13 | // MessageCallMissed is the payload for sending and missed call event 14 | type MessageCallMissed struct { 15 | request 16 | From string `json:"from" example:"+18005550199"` 17 | To string `json:"to" example:"+18005550100"` 18 | SIM string `json:"sim" example:"SIM1"` 19 | Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"` 20 | } 21 | 22 | // Sanitize sets defaults to MessageReceive 23 | func (input *MessageCallMissed) Sanitize() MessageCallMissed { 24 | input.To = input.sanitizeAddress(input.To) 25 | input.From = input.sanitizeContact(input.To, input.From) 26 | input.SIM = input.sanitizeSIM(input.SIM) 27 | 28 | return *input 29 | } 30 | 31 | // ToCallMissedParams converts MessageCallMissed to services.MessageSendParams 32 | func (input *MessageCallMissed) ToCallMissedParams(userID entities.UserID, source string) *services.MissedCallParams { 33 | to, _ := phonenumbers.Parse(input.To, phonenumbers.UNKNOWN_REGION) 34 | return &services.MissedCallParams{ 35 | Source: source, 36 | Owner: to, 37 | Timestamp: input.Timestamp, 38 | SIM: entities.SIM(input.SIM), 39 | UserID: userID, 40 | Contact: input.From, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/pkg/repositories/user_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | ) 8 | 9 | // UserRepository loads and persists an entities.User 10 | type UserRepository interface { 11 | // Store a new entities.User 12 | Store(ctx context.Context, user *entities.User) error 13 | 14 | // Update a new entities.User 15 | Update(ctx context.Context, user *entities.User) error 16 | 17 | // LoadAuthContext fetches an entities.AuthContext by apiKey 18 | LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) 19 | 20 | // Load an entities.User by entities.UserID 21 | Load(ctx context.Context, userID entities.UserID) (*entities.User, error) 22 | 23 | // RotateAPIKey updates the API Key of a user 24 | RotateAPIKey(ctx context.Context, userID entities.UserID) (*entities.User, error) 25 | 26 | // LoadOrStore an entities.User by entities.AuthContext 27 | LoadOrStore(ctx context.Context, user entities.AuthContext) (*entities.User, bool, error) 28 | 29 | // LoadBySubscriptionID loads a user based on the lemonsqueezy subscriptionID 30 | LoadBySubscriptionID(ctx context.Context, subscriptionID string) (*entities.User, error) 31 | 32 | // LoadByEmail loads a user based on the email 33 | LoadByEmail(ctx context.Context, email string) (*entities.User, error) 34 | 35 | // Delete an entities.User by entities.UserID 36 | Delete(ctx context.Context, user *entities.User) error 37 | } 38 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | android.nonFinalResIds=false 25 | -------------------------------------------------------------------------------- /api/pkg/requests/integration_3cx_message_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/nyaruka/phonenumbers" 9 | 10 | "github.com/NdoleStudio/httpsms/pkg/services" 11 | ) 12 | 13 | // Integration3CXMessage is the payload for sending and SMS message via 3CX 14 | type Integration3CXMessage struct { 15 | request 16 | From string `json:"from" example:"+18005550199"` 17 | To string `json:"to" example:"+18005550100"` 18 | Text string `json:"text" example:"This is a sample text message"` 19 | } 20 | 21 | // Sanitize sets defaults to MessageReceive 22 | func (input *Integration3CXMessage) Sanitize() Integration3CXMessage { 23 | input.To = input.sanitizeAddress(input.To) 24 | input.From = input.sanitizeAddress(input.From) 25 | return *input 26 | } 27 | 28 | // ToMessageSendParams converts Integration3CXMessage to services.MessageSendParams 29 | func (input *Integration3CXMessage) ToMessageSendParams(userID entities.UserID, source string) services.MessageSendParams { 30 | from, _ := phonenumbers.Parse(input.From, phonenumbers.UNKNOWN_REGION) 31 | return services.MessageSendParams{ 32 | Source: source, 33 | Owner: from, 34 | RequestID: input.sanitizeStringPointer("integration-3cx"), 35 | UserID: userID, 36 | RequestReceivedAt: time.Now().UTC(), 37 | Contact: input.sanitizeAddress(input.To), 38 | Content: input.Text, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/pkg/requests/heartbeat_store_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/NdoleStudio/httpsms/pkg/services" 8 | ) 9 | 10 | // HeartbeatStore is the payload for fetching entities.Heartbeat of a phone number 11 | type HeartbeatStore struct { 12 | request 13 | Owner string `json:"owner" swaggerignore:"true"` 14 | Charging bool `json:"charging"` 15 | PhoneNumbers []string `json:"phone_numbers"` 16 | } 17 | 18 | // Sanitize sets defaults to MessageOutstanding 19 | func (input *HeartbeatStore) Sanitize() HeartbeatStore { 20 | input.Owner = input.sanitizeAddress(input.Owner) 21 | 22 | input.PhoneNumbers = input.sanitizeAddresses(input.PhoneNumbers) 23 | if len(input.PhoneNumbers) == 0 { 24 | input.PhoneNumbers = append(input.PhoneNumbers, input.Owner) 25 | } 26 | 27 | return *input 28 | } 29 | 30 | // ToStoreParams converts HeartbeatIndex to repositories.IndexParams 31 | func (input *HeartbeatStore) ToStoreParams(user entities.AuthContext, source string, version string) []services.HeartbeatStoreParams { 32 | var params []services.HeartbeatStoreParams 33 | for _, phoneNumber := range input.PhoneNumbers { 34 | params = append(params, services.HeartbeatStoreParams{ 35 | Owner: phoneNumber, 36 | Charging: input.Charging, 37 | Source: source, 38 | Version: version, 39 | UserID: user.ID, 40 | Timestamp: time.Now(), 41 | }) 42 | } 43 | return params 44 | } 45 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/validators/PhoneNumberValidator.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms.validators 2 | 3 | import com.google.i18n.phonenumbers.PhoneNumberUtil 4 | import timber.log.Timber 5 | 6 | class PhoneNumberValidator { 7 | companion object { 8 | private val phoneNumberUtil = PhoneNumberUtil.getInstance() 9 | fun isValidPhoneNumber(phoneNumber: String, countryCode: String): Boolean { 10 | Timber.e(countryCode) 11 | return try { 12 | if (phoneNumber.isEmpty()) { 13 | return false 14 | } 15 | val number = phoneNumberUtil.parse(fixNumber(phoneNumber), countryCode) 16 | phoneNumberUtil.isValidNumber(number) 17 | } catch (e: Exception) { 18 | false 19 | } 20 | } 21 | fun formatE164(phoneNumber: String, countryCode: String): String { 22 | return try { 23 | val number = phoneNumberUtil.parse(fixNumber(phoneNumber), countryCode) 24 | phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164) 25 | } catch (e: Exception) { 26 | phoneNumber 27 | } 28 | } 29 | 30 | private fun fixNumber(phoneNumber: String): String { 31 | if (phoneNumber.length >= 11 && !phoneNumber.startsWith("+")) { 32 | return "+${phoneNumber}" 33 | } 34 | return phoneNumber 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/pkg/middlewares/bearer_api_key_auth_middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/NdoleStudio/httpsms/pkg/repositories" 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/palantir/stacktrace" 11 | ) 12 | 13 | // BearerAPIKeyAuth authenticates an API key using the Bearer header 14 | func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository repositories.UserRepository) fiber.Handler { 15 | logger = logger.WithService("middlewares.APIKeyAuth") 16 | 17 | return func(c *fiber.Ctx) error { 18 | ctx, span := tracer.StartFromFiberCtx(c, "middlewares.APIKeyAuth") 19 | defer span.End() 20 | 21 | ctxLogger := tracer.CtxLogger(logger, span) 22 | 23 | apiKey := strings.TrimSpace(strings.Replace(c.Get(authHeaderBearer), bearerScheme, "", 1)) 24 | if len(apiKey) == 0 { 25 | span.AddEvent(fmt.Sprintf("the request header has no [%s] api key", authHeaderAPIKey)) 26 | return c.Next() 27 | } 28 | 29 | authUser, err := userRepository.LoadAuthContext(ctx, apiKey) 30 | if err != nil { 31 | ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load user with api key [%s] using header [%s]", apiKey, c.Get(authHeaderBearer)))) 32 | return c.Next() 33 | } 34 | 35 | c.Locals(ContextKeyAuthUserID, authUser) 36 | 37 | ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) 38 | 39 | return c.Next() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "877524083399", 4 | "project_id": "httpsms-86c51", 5 | "storage_bucket": "httpsms-86c51.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:877524083399:android:5ceb08a909fd24a96514e2", 11 | "android_client_info": { 12 | "package_name": "com.httpsms" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "877524083399-londfmd80qqlb2gebpbhctavlmqtla3b.apps.googleusercontent.com", 18 | "client_type": 1, 19 | "android_info": { 20 | "package_name": "com.httpsms", 21 | "certificate_hash": "58df9f7489b8bb5e3cd57f01a03d5e0068d8c185" 22 | } 23 | }, 24 | { 25 | "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", 26 | "client_type": 3 27 | } 28 | ], 29 | "api_key": [ 30 | { 31 | "current_key": "AIzaSyBKiokmk5qhE2Zc5F97KWzOvRe8FDDuxDg" 32 | } 33 | ], 34 | "services": { 35 | "appinvite_service": { 36 | "other_platform_oauth_client": [ 37 | { 38 | "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com", 39 | "client_type": 3 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | ], 46 | "configuration_version": "1" 47 | } 48 | -------------------------------------------------------------------------------- /web/components/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 47 | -------------------------------------------------------------------------------- /api/pkg/requests/phone_fcm_token_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/nyaruka/phonenumbers" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | "github.com/NdoleStudio/httpsms/pkg/services" 10 | ) 11 | 12 | // PhoneFCMToken is the payload for updating the FCM token of a phone 13 | type PhoneFCMToken struct { 14 | request 15 | PhoneNumber string `json:"phone_number" example:"[+18005550199]"` 16 | FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` 17 | // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot 18 | SIM string `json:"sim" example:"SIM1"` 19 | } 20 | 21 | // Sanitize sets defaults to MessageOutstanding 22 | func (input *PhoneFCMToken) Sanitize() PhoneFCMToken { 23 | input.FcmToken = strings.TrimSpace(input.FcmToken) 24 | input.PhoneNumber = input.sanitizeAddress(input.PhoneNumber) 25 | input.SIM = input.sanitizeSIM(input.SIM) 26 | return *input 27 | } 28 | 29 | // ToPhoneFCMTokenParams converts PhoneFCMToken to services.PhoneFCMTokenParams 30 | func (input *PhoneFCMToken) ToPhoneFCMTokenParams(user entities.AuthContext, source string) *services.PhoneFCMTokenParams { 31 | phone, _ := phonenumbers.Parse(input.PhoneNumber, phonenumbers.UNKNOWN_REGION) 32 | return &services.PhoneFCMTokenParams{ 33 | Source: source, 34 | PhoneNumber: phone, 35 | PhoneAPIKeyID: user.PhoneAPIKeyID, 36 | UserID: user.ID, 37 | FcmToken: &input.FcmToken, 38 | SIM: entities.SIM(input.SIM), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/pkg/repositories/message_thread_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // MessageThreadRepository loads and persists an entities.MessageThread 12 | type MessageThreadRepository interface { 13 | // Store a new entities.MessageThread 14 | Store(ctx context.Context, thread *entities.MessageThread) error 15 | 16 | // Update a new entities.MessageThread 17 | Update(ctx context.Context, thread *entities.MessageThread) error 18 | 19 | // LoadByOwnerContact fetches a thread between owner and contact 20 | LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) 21 | 22 | // Load a thread by ID 23 | Load(ctx context.Context, userID entities.UserID, ID uuid.UUID) (*entities.MessageThread, error) 24 | 25 | // Index message threads for an owner 26 | Index(ctx context.Context, userID entities.UserID, owner string, archived bool, params IndexParams) (*[]entities.MessageThread, error) 27 | 28 | // UpdateAfterDeletedMessage updates a thread after the original message has been deleted 29 | UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error 30 | 31 | // Delete an entities.MessageThread by ID 32 | Delete(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID) error 33 | 34 | // DeleteAllForUser deletes all entities.MessageThread for a user 35 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 36 | } 37 | -------------------------------------------------------------------------------- /api/pkg/telemetry/tracer.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "go.opentelemetry.io/otel/trace" 8 | ) 9 | 10 | const ( 11 | // TracerContextKey stores the fiber trace context 12 | TracerContextKey = "tracer.context.key" 13 | ) 14 | 15 | // Tracer is used for tracing 16 | type Tracer interface { 17 | // StartFromFiberCtx creates a spanContext and a context.Context containing the newly-created spanContext. 18 | StartFromFiberCtx(c *fiber.Ctx, name ...string) (context.Context, trace.Span) 19 | 20 | // StartFromFiberCtxWithLogger creates a spanContext and a context.Context containing the newly-created spanContext with a logger 21 | StartFromFiberCtxWithLogger(c *fiber.Ctx, logger Logger, name ...string) (context.Context, trace.Span, Logger) 22 | 23 | // Start creates a spanContext and a context.Context containing the newly-created spanContext. 24 | Start(c context.Context, name ...string) (context.Context, trace.Span) 25 | 26 | // StartWithLogger creates a spanContext and a context.Context containing the newly-created spanContext with a logger 27 | StartWithLogger(c context.Context, logger Logger, name ...string) (context.Context, trace.Span, Logger) 28 | 29 | // CtxLogger creates a telemetry.Logger with spanContext attributes in the structured logger 30 | CtxLogger(logger Logger, span trace.Span) Logger 31 | 32 | // WrapErrorSpan sets a spanContext as error 33 | WrapErrorSpan(span trace.Span, err error) error 34 | 35 | // Span returns the trace.Span from context.Context 36 | Span(ctx context.Context) trace.Span 37 | } 38 | -------------------------------------------------------------------------------- /web/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 56 | -------------------------------------------------------------------------------- /web/plugins/bag.ts: -------------------------------------------------------------------------------- 1 | export default class Bag { 2 | private items = new Map>() 3 | 4 | serialize(): { [name: string]: Array } { 5 | const result = {} 6 | this.items.forEach((value: T[], key) => { 7 | // @ts-ignore 8 | result[key] = value 9 | }) 10 | return result 11 | } 12 | 13 | static fromObject(items: object): Bag { 14 | const result = new Bag() 15 | Object.keys(items).forEach((key) => { 16 | // @ts-ignore 17 | result.addMany(key as K, items[key]) 18 | }) 19 | return result 20 | } 21 | 22 | add(key: string, value: T): this { 23 | let messages: Array | undefined = this.items.get(key) 24 | if (messages === undefined) { 25 | messages = [] 26 | } 27 | 28 | if (!messages.includes(value)) { 29 | messages.push(value) 30 | } 31 | 32 | this.items.set(key, messages) 33 | 34 | return this 35 | } 36 | 37 | addMany(key: string, values: Array): this { 38 | values.forEach((value: T) => { 39 | this.add(key, value) 40 | }) 41 | return this 42 | } 43 | 44 | has(key: string): boolean { 45 | return this.items.has(key) 46 | } 47 | 48 | first(key: string): T | undefined { 49 | if (this.has(key)) { 50 | return this.get(key)[0] ?? undefined 51 | } 52 | return undefined 53 | } 54 | 55 | get(key: string): Array { 56 | const result = this.items.get(key) 57 | if (result === undefined) { 58 | return [] 59 | } 60 | return result 61 | } 62 | 63 | size(): number { 64 | return this.items.size 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /api/pkg/emails/smtp_mailer_service.go: -------------------------------------------------------------------------------- 1 | package emails 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/smtp" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | mail "github.com/jordan-wright/email" 10 | "github.com/palantir/stacktrace" 11 | ) 12 | 13 | // SMTPConfig is the config for setting up the smtpMailer 14 | type SMTPConfig struct { 15 | FromName string 16 | FromEmail string 17 | Username string 18 | Password string 19 | Hostname string 20 | Port string 21 | } 22 | 23 | type smtpMailer struct { 24 | address string 25 | from string 26 | tracer telemetry.Tracer 27 | auth smtp.Auth 28 | } 29 | 30 | // NewSMTPEmailService creates a new instance of the smtpMailer 31 | func NewSMTPEmailService(tracer telemetry.Tracer, config SMTPConfig) Mailer { 32 | return &smtpMailer{ 33 | tracer: tracer, 34 | auth: smtp.PlainAuth("", config.Username, config.Password, config.Hostname), 35 | address: fmt.Sprintf("%s:%s", config.Hostname, config.Port), 36 | from: fmt.Sprintf("%s <%s>", config.FromName, config.FromEmail), 37 | } 38 | } 39 | 40 | // Send a new email 41 | func (mailer *smtpMailer) Send(ctx context.Context, email *Email) (err error) { 42 | ctx, span := mailer.tracer.Start(ctx) 43 | defer span.End() 44 | 45 | e := mail.NewEmail() 46 | e.From = mailer.from 47 | e.To = []string{email.toAddress()} 48 | e.Subject = email.Subject 49 | e.Text = []byte(email.Text) 50 | e.HTML = []byte(email.HTML) 51 | 52 | err = e.Send(mailer.address, mailer.auth) 53 | if err != nil { 54 | return stacktrace.Propagate(err, "cannot send email") 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /api/pkg/cache/redis_cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 10 | "github.com/palantir/stacktrace" 11 | "github.com/redis/go-redis/v9" 12 | ) 13 | 14 | // redisCache is the Cache implementation in redis 15 | type redisCache struct { 16 | tracer telemetry.Tracer 17 | client *redis.Client 18 | } 19 | 20 | // NewRedisCache creates a new instance of RedisCache 21 | func NewRedisCache(tracer telemetry.Tracer, client *redis.Client) Cache { 22 | return &redisCache{ 23 | tracer: tracer, 24 | client: client, 25 | } 26 | } 27 | 28 | // Get an item from the redis cache 29 | func (cache *redisCache) Get(ctx context.Context, key string) (value string, err error) { 30 | ctx, span := cache.tracer.Start(ctx) 31 | defer span.End() 32 | 33 | response, err := cache.client.Get(ctx, key).Result() 34 | if errors.Is(err, redis.Nil) { 35 | return "", stacktrace.Propagate(err, fmt.Sprintf("no item found in redis with key [%s]", key)) 36 | } 37 | if err != nil { 38 | return "", stacktrace.Propagate(err, fmt.Sprintf("cannot get item in redis with key [%s]", key)) 39 | } 40 | return response, nil 41 | } 42 | 43 | // Set an item in the redis cache 44 | func (cache *redisCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error { 45 | ctx, span := cache.tracer.Start(ctx) 46 | defer span.End() 47 | 48 | err := cache.client.Set(ctx, key, value, ttl).Err() 49 | if err != nil { 50 | return cache.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot set item in redis")) 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /api/pkg/telemetry/gorm_logger.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/palantir/stacktrace" 9 | "gorm.io/gorm/logger" 10 | ) 11 | 12 | type gormLogger struct { 13 | tracer Tracer 14 | logger Logger 15 | } 16 | 17 | // NewGormLogger creates a new instance of gormLogger 18 | func NewGormLogger(tracer Tracer, logger Logger) logger.Interface { 19 | return &gormLogger{ 20 | tracer: tracer, 21 | logger: logger, 22 | } 23 | } 24 | 25 | // LogMode log mode 26 | func (gorm *gormLogger) LogMode(_ logger.LogLevel) logger.Interface { 27 | return gorm 28 | } 29 | 30 | func (gorm *gormLogger) Info(ctx context.Context, s string, i ...interface{}) { 31 | gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Info(fmt.Sprintf(s, i...)) 32 | } 33 | 34 | func (gorm *gormLogger) Warn(ctx context.Context, s string, i ...interface{}) { 35 | gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Warn(fmt.Errorf(s, i...)) 36 | } 37 | 38 | func (gorm *gormLogger) Error(ctx context.Context, s string, i ...interface{}) { 39 | gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Error(fmt.Errorf(s, i...)) 40 | } 41 | 42 | func (gorm *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { 43 | elapsed := time.Since(begin) 44 | l := gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).WithString("latency", elapsed.String()) 45 | sql, rows := fc() 46 | msg := fmt.Sprintf("[ROWS:%d][%s]", rows, sql) 47 | 48 | if err != nil { 49 | l.Error(stacktrace.Propagate(err, msg)) 50 | return 51 | } 52 | 53 | l.Debug(msg) 54 | } 55 | -------------------------------------------------------------------------------- /web/pages/threads/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 58 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | defaults: 9 | run: 10 | working-directory: ./web 11 | 12 | jobs: 13 | ci: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest] 19 | node: [20] 20 | 21 | steps: 22 | - name: Checkout 🛎 23 | uses: actions/checkout@master 24 | 25 | - uses: pnpm/action-setup@v4 26 | name: Install pnpm 27 | with: 28 | version: 9 29 | 30 | - name: Install dependencies 📦 31 | run: pnpm install 32 | 33 | - name: Run linter 👀 34 | run: pnpm lint 35 | 36 | - name: Run tests 🧪 37 | run: pnpm test 38 | 39 | - name: Debug 🐛 40 | run: echo GITHUB_SHA=${GITHUB_SHA} 41 | 42 | - name: Build 🏗️ 43 | run: mv .env.production .env && echo GITHUB_SHA=${GITHUB_SHA} >> .env && pnpm run generate 44 | 45 | - name: Cloudflare Deploy 🚀 46 | uses: cloudflare/pages-action@1 47 | with: 48 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 49 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 50 | projectName: httpsms 51 | directory: web/dist 52 | gitHubToken: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: Firebase Deploy 🚀 55 | uses: FirebaseExtended/action-hosting-deploy@v0 56 | with: 57 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 58 | channelId: live 59 | entryPoint: "./web" 60 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_HTTPSMS_86C51 }}" 61 | projectId: httpsms-86c51 62 | -------------------------------------------------------------------------------- /api/pkg/services/service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "time" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 9 | "github.com/nyaruka/phonenumbers" 10 | 11 | cloudevents "github.com/cloudevents/sdk-go/v2" 12 | "github.com/google/uuid" 13 | "github.com/palantir/stacktrace" 14 | ) 15 | 16 | type service struct{} 17 | 18 | func (service *service) createEvent(eventType string, source string, payload any) (cloudevents.Event, error) { 19 | event := cloudevents.NewEvent() 20 | 21 | event.SetSource(source) 22 | event.SetType(eventType) 23 | event.SetTime(time.Now().UTC()) 24 | event.SetID(uuid.New().String()) 25 | 26 | if err := event.SetData(cloudevents.ApplicationJSON, payload); err != nil { 27 | msg := fmt.Sprintf("cannot encode %T [%#+v] as JSON", payload, payload) 28 | return event, stacktrace.Propagate(err, msg) 29 | } 30 | 31 | return event, nil 32 | } 33 | 34 | func (service *service) getFormattedNumber(ctxLogger telemetry.Logger, phoneNumber string) string { 35 | matched, err := regexp.MatchString("^\\+?[1-9]\\d{9,14}$", phoneNumber) 36 | if err != nil { 37 | ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("error while matching phoneNumber [%s] with regex [%s]", phoneNumber, "^\\+?[1-9]\\d{10,14}$"))) 38 | return phoneNumber 39 | } 40 | if !matched { 41 | return phoneNumber 42 | } 43 | 44 | number, err := phonenumbers.Parse(phoneNumber, phonenumbers.UNKNOWN_REGION) 45 | if err != nil { 46 | ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot parse number [%s]", phoneNumber))) 47 | return phoneNumber 48 | } 49 | 50 | return phonenumbers.Format(number, phonenumbers.INTERNATIONAL) 51 | } 52 | -------------------------------------------------------------------------------- /api/pkg/requests/bulk_message_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | "github.com/NdoleStudio/httpsms/pkg/services" 10 | "github.com/google/uuid" 11 | "github.com/nyaruka/phonenumbers" 12 | ) 13 | 14 | // BulkMessage represents a single message in a bulk SMS request 15 | type BulkMessage struct { 16 | request 17 | FromPhoneNumber string `csv:"FromPhoneNumber"` 18 | ToPhoneNumber string `csv:"ToPhoneNumber"` 19 | Content string `csv:"Content"` 20 | SendTime *time.Time `csv:"SendTime(optional)"` 21 | } 22 | 23 | // Sanitize sets defaults to BulkMessage 24 | func (input *BulkMessage) Sanitize() *BulkMessage { 25 | input.ToPhoneNumber = input.sanitizeAddress(input.ToPhoneNumber) 26 | input.Content = strings.TrimSpace(input.Content) 27 | input.FromPhoneNumber = input.sanitizeAddress(input.FromPhoneNumber) 28 | return input 29 | } 30 | 31 | // ToMessageSendParams converts BulkMessage to services.MessageSendParams 32 | func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams { 33 | from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) 34 | return services.MessageSendParams{ 35 | Source: source, 36 | Owner: from, 37 | RequestID: input.sanitizeStringPointer(fmt.Sprintf("bulk-%s", requestID.String())), 38 | UserID: userID, 39 | SendAt: input.SendTime, 40 | RequestReceivedAt: time.Now().UTC(), 41 | Contact: input.sanitizeAddress(input.ToPhoneNumber), 42 | Content: input.Content, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/pages/login.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 59 | -------------------------------------------------------------------------------- /api/pkg/discord/client_option_test.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestWithHTTPClient(t *testing.T) { 11 | t.Run("httpClient is not set when the httpClient is nil", func(t *testing.T) { 12 | // Setup 13 | t.Parallel() 14 | 15 | // Arrange 16 | config := defaultClientConfig() 17 | 18 | // Act 19 | WithHTTPClient(nil).apply(config) 20 | 21 | // Assert 22 | assert.NotNil(t, config.httpClient) 23 | }) 24 | 25 | t.Run("httpClient is set when the httpClient is not nil", func(t *testing.T) { 26 | // Setup 27 | t.Parallel() 28 | 29 | // Arrange 30 | config := defaultClientConfig() 31 | newClient := &http.Client{Timeout: 300} 32 | 33 | // Act 34 | WithHTTPClient(newClient).apply(config) 35 | 36 | // Assert 37 | assert.NotNil(t, config.httpClient) 38 | assert.Equal(t, newClient.Timeout, config.httpClient.Timeout) 39 | }) 40 | } 41 | 42 | func TestWithBaseURL(t *testing.T) { 43 | t.Run("baseURL is set successfully", func(t *testing.T) { 44 | // Setup 45 | t.Parallel() 46 | 47 | // Arrange 48 | baseURL := "https://example.com" 49 | config := defaultClientConfig() 50 | 51 | // Act 52 | WithBaseURL(baseURL).apply(config) 53 | 54 | // Assert 55 | assert.Equal(t, config.baseURL, config.baseURL) 56 | }) 57 | 58 | t.Run("tailing / is trimmed from baseURL", func(t *testing.T) { 59 | // Setup 60 | t.Parallel() 61 | 62 | // Arrange 63 | baseURL := "https://example.com/" 64 | config := defaultClientConfig() 65 | 66 | // Act 67 | WithBaseURL(baseURL).apply(config) 68 | 69 | // Assert 70 | assert.Equal(t, "https://example.com", config.baseURL) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /api/pkg/repositories/phone_api_key_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/entities" 9 | ) 10 | 11 | // PhoneAPIKeyRepository loads and persists an entities.PhoneAPIKey 12 | type PhoneAPIKeyRepository interface { 13 | // Create a new entities.PhoneAPIKey 14 | Create(ctx context.Context, phone *entities.PhoneAPIKey) error 15 | 16 | // Load an entities.PhoneAPIKey by userID and phoneAPIKeyID 17 | Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) 18 | 19 | // LoadAuthContext fetches an entities.AuthContext by apiKey 20 | LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) 21 | 22 | // Index entities.PhoneAPIKey of a user 23 | Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.PhoneAPIKey, error) 24 | 25 | // Delete an entities.PhoneAPIKey 26 | Delete(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error 27 | 28 | // AddPhone adds an entities.Phone to an entities.PhoneAPIKey 29 | AddPhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error 30 | 31 | // RemovePhone removes an entities.Phone to an entities.PhoneAPIKey 32 | RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error 33 | 34 | // DeleteAllForUser deletes all entities.PhoneAPIKey for a user 35 | DeleteAllForUser(ctx context.Context, userID entities.UserID) error 36 | 37 | // RemovePhoneByID removes a phone by ID and phone number 38 | RemovePhoneByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID, phoneNumber string) error 39 | } 40 | -------------------------------------------------------------------------------- /api/pkg/discord/channel_service.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // ChannelService is the API client for interacting with channels 11 | type ChannelService service 12 | 13 | // CreateMessage sends a message to a guild text or DM channel. 14 | // 15 | // API Docs: https://discord.com/developers/docs/resources/channel#create-message 16 | func (service *ChannelService) CreateMessage(ctx context.Context, channelID string, payload map[string]any) (map[string]any, *Response, error) { 17 | request, err := service.client.newRequest(ctx, http.MethodPost, fmt.Sprintf("/channels/%s/messages", channelID), payload) 18 | if err != nil { 19 | return nil, nil, err 20 | } 21 | 22 | response, err := service.client.do(request) 23 | if err != nil { 24 | return nil, response, err 25 | } 26 | 27 | message := map[string]any{} 28 | if err = json.Unmarshal(*response.Body, &message); err != nil { 29 | return nil, response, err 30 | } 31 | 32 | return message, response, nil 33 | } 34 | 35 | // Get a channel by ID 36 | // 37 | // API Docs: https://discord.com/developers/docs/resources/channel#get-channel 38 | func (service *ChannelService) Get(ctx context.Context, channelID string) (map[string]any, *Response, error) { 39 | request, err := service.client.newRequest(ctx, http.MethodGet, fmt.Sprintf("/channels/%s", channelID), nil) 40 | if err != nil { 41 | return nil, nil, err 42 | } 43 | 44 | response, err := service.client.do(request) 45 | if err != nil { 46 | return nil, response, err 47 | } 48 | 49 | channel := map[string]any{} 50 | if err = json.Unmarshal(*response.Body, &channel); err != nil { 51 | return nil, response, err 52 | } 53 | 54 | return channel, response, nil 55 | } 56 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms.worker 2 | 3 | import android.content.Context 4 | import androidx.work.Worker 5 | import androidx.work.WorkerParameters 6 | import com.httpsms.Constants 7 | import com.httpsms.HttpSmsApiService 8 | import com.httpsms.Settings 9 | import timber.log.Timber 10 | 11 | class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { 12 | override fun doWork(): Result { 13 | Timber.d("executing heartbeat worker") 14 | if (!Settings.isLoggedIn(applicationContext)) { 15 | Timber.w("user is not logged in, stopping processing") 16 | return Result.failure() 17 | } 18 | 19 | val phoneNumbers = mutableListOf() 20 | if (Settings.getActiveStatus(applicationContext, Constants.SIM1)) { 21 | phoneNumbers.add(Settings.getSIM1PhoneNumber(applicationContext)) 22 | } 23 | if (Settings.getActiveStatus(applicationContext, Constants.SIM2)) { 24 | phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) 25 | } 26 | 27 | if (phoneNumbers.isEmpty()) { 28 | Timber.w("both [SIM1] and [SIM2] are inactive stopping processing.") 29 | return Result.success() 30 | } 31 | 32 | HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext)) 33 | Timber.d("finished sending heartbeats to server") 34 | 35 | Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) 36 | Timber.d("Set the heartbeat timestamp") 37 | 38 | return Result.success() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/Models.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms 2 | 3 | import com.beust.klaxon.Json 4 | import com.beust.klaxon.Klaxon 5 | 6 | data class ResponseMessage ( 7 | val data: Message, 8 | val message: String, 9 | val status: String 10 | ) { 11 | companion object { 12 | fun fromJson(json: String) = Klaxon().parse(json) 13 | } 14 | } 15 | data class ResponsePhone ( 16 | val data: Phone, 17 | val message: String, 18 | val status: String, 19 | ) { 20 | companion object { 21 | fun fromJson(json: String) = Klaxon().parse(json) 22 | } 23 | } 24 | 25 | data class Phone ( 26 | val id: String, 27 | 28 | @Json(name = "user_id") 29 | val userID: String, 30 | ) 31 | 32 | data class Message ( 33 | val contact: String, 34 | val content: String, 35 | val sim: String, 36 | 37 | @Json(name = "created_at") 38 | val createdAt: String, 39 | 40 | @Json(name = "failure_reason") 41 | val failureReason: String?, 42 | 43 | val id: String, 44 | 45 | @Json(name = "last_attempted_at") 46 | val lastAttemptedAt: String?, 47 | 48 | @Json(name = "order_timestamp") 49 | val orderTimestamp: String, 50 | 51 | val owner: String, 52 | 53 | @Json(name = "received_at") 54 | val receivedAt: String?, 55 | 56 | val encrypted: Boolean, 57 | 58 | @Json(name = "request_received_at") 59 | val requestReceivedAt: String, 60 | 61 | @Json(name = "send_time") 62 | val sendTime: Long?, 63 | 64 | @Json(name = "sent_at") 65 | val sentAt: String?, 66 | 67 | val status: String, 68 | val type: String, 69 | 70 | @Json(name = "updated_at") 71 | val updatedAt: String 72 | ) 73 | -------------------------------------------------------------------------------- /api/pkg/requests/message_event_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | "github.com/google/uuid" 8 | 9 | "github.com/NdoleStudio/httpsms/pkg/services" 10 | ) 11 | 12 | // MessageEvent is the payload for sending and SMS message 13 | type MessageEvent struct { 14 | request 15 | 16 | // Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible 17 | Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"` 18 | 19 | // EventName is the type of event 20 | // * SENT: is emitted when a message is sent by the mobile phone 21 | // * FAILED: is event is emitted when the message could not be sent by the mobile phone 22 | // * DELIVERED: is event is emitted when a delivery report has been received by the mobile phone 23 | EventName string `json:"event_name" example:"SENT"` 24 | 25 | // Reason is the exact error message in case the event is an error 26 | Reason *string `json:"reason"` 27 | 28 | MessageID string `json:"messageID" swaggerignore:"true"` // used internally for validation 29 | } 30 | 31 | // Sanitize the message event 32 | func (input *MessageEvent) Sanitize() MessageEvent { 33 | input.MessageID = input.sanitizeMessageID(input.MessageID) 34 | return *input 35 | } 36 | 37 | // ToMessageStoreEventParams converts MessageEvent to services.MessageStoreEventParams 38 | func (input *MessageEvent) ToMessageStoreEventParams(source string) services.MessageStoreEventParams { 39 | return services.MessageStoreEventParams{ 40 | MessageID: uuid.MustParse(input.MessageID), 41 | Source: source, 42 | ErrorMessage: input.Reason, 43 | EventName: entities.MessageEventName(input.EventName), 44 | Timestamp: input.Timestamp, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/pkg/middlewares/bearer_auth_middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "firebase.google.com/go/auth" 9 | "github.com/NdoleStudio/httpsms/pkg/entities" 10 | "github.com/NdoleStudio/httpsms/pkg/telemetry" 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/palantir/stacktrace" 13 | ) 14 | 15 | // BearerAuth authenticates a user based on the bearer token 16 | func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *auth.Client) fiber.Handler { 17 | logger = logger.WithService("middlewares.BearerAuth") 18 | return func(c *fiber.Ctx) error { 19 | _, span := tracer.StartFromFiberCtx(c, "middlewares.BearerAuth") 20 | defer span.End() 21 | 22 | authToken := c.Get(authHeaderBearer) 23 | if !strings.HasPrefix(authToken, bearerScheme) { 24 | span.AddEvent(fmt.Sprintf("The request header has no [%s] token", bearerScheme)) 25 | return c.Next() 26 | } 27 | 28 | if len(authToken) > len(bearerScheme)+1 { 29 | authToken = authToken[len(bearerScheme)+1:] 30 | } 31 | 32 | ctxLogger := tracer.CtxLogger(logger, span) 33 | 34 | token, err := authClient.VerifyIDToken(context.Background(), authToken) 35 | if err != nil { 36 | msg := fmt.Sprintf("invalid firebase id token [%s]", authToken) 37 | ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) 38 | return c.Next() 39 | } 40 | 41 | span.AddEvent(fmt.Sprintf("[%s] token is valid", bearerScheme)) 42 | 43 | authUser := entities.AuthContext{ 44 | Email: token.Claims["email"].(string), 45 | ID: entities.UserID(token.Claims["user_id"].(string)), 46 | } 47 | 48 | c.Locals(ContextKeyAuthUserID, authUser) 49 | 50 | ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) 51 | return c.Next() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/pkg/requests/message_thread_index_request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/NdoleStudio/httpsms/pkg/entities" 7 | 8 | "github.com/NdoleStudio/httpsms/pkg/repositories" 9 | 10 | "github.com/NdoleStudio/httpsms/pkg/services" 11 | ) 12 | 13 | // MessageThreadIndex is the payload fetching entities.MessageThread sent between 2 numbers 14 | type MessageThreadIndex struct { 15 | request 16 | IsArchived string `json:"is_archived" query:"is_archived" example:"false"` 17 | Skip string `json:"skip" query:"skip"` 18 | Query string `json:"query" query:"query"` 19 | Limit string `json:"limit" query:"limit"` 20 | Owner string `json:"owner" query:"owner"` 21 | } 22 | 23 | // Sanitize sets defaults to MessageOutstanding 24 | func (input *MessageThreadIndex) Sanitize() MessageThreadIndex { 25 | if strings.TrimSpace(input.Limit) == "" { 26 | input.Limit = "20" 27 | } 28 | 29 | if strings.TrimSpace(input.IsArchived) == "" { 30 | input.IsArchived = "false" 31 | } 32 | 33 | input.IsArchived = input.sanitizeBool(input.IsArchived) 34 | input.Query = strings.TrimSpace(input.Query) 35 | input.Owner = input.sanitizeAddress(input.Owner) 36 | 37 | input.Skip = strings.TrimSpace(input.Skip) 38 | if input.Skip == "" { 39 | input.Skip = "0" 40 | } 41 | 42 | return *input 43 | } 44 | 45 | // ToGetParams converts MessageThreadIndex into services.MessageThreadGetParams 46 | func (input *MessageThreadIndex) ToGetParams(userID entities.UserID) services.MessageThreadGetParams { 47 | return services.MessageThreadGetParams{ 48 | IndexParams: repositories.IndexParams{ 49 | Skip: input.getInt(input.Skip), 50 | Query: input.Query, 51 | Limit: input.getInt(input.Limit), 52 | }, 53 | UserID: userID, 54 | IsArchived: input.getBool(input.IsArchived), 55 | Owner: input.Owner, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/httpsms/Encrypter.kt: -------------------------------------------------------------------------------- 1 | package com.httpsms 2 | 3 | import timber.log.Timber 4 | import java.security.MessageDigest 5 | import java.util.Base64 6 | import java.util.Random 7 | import javax.crypto.Cipher 8 | import javax.crypto.spec.IvParameterSpec 9 | import javax.crypto.spec.SecretKeySpec 10 | 11 | object Encrypter { 12 | private const val ALGORITHM = "AES/CFB/NoPadding" 13 | private const val IV_SIZE = 16 14 | 15 | fun decrypt(key: String, cipherText: String): String { 16 | val cipher = Cipher.getInstance(ALGORITHM) 17 | val cipherBytes = Base64.getDecoder().decode(cipherText) 18 | Timber.d("iv = ${Base64.getEncoder().encodeToString(cipherBytes.take(IV_SIZE).toByteArray())}") 19 | Timber.d("cipher = ${Base64.getEncoder().encodeToString(cipherBytes.drop(IV_SIZE).toByteArray())}") 20 | cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(hash(key), "AES"), IvParameterSpec(cipherBytes.take(IV_SIZE).toByteArray())) 21 | val plainText = cipher.doFinal(cipherBytes.drop(IV_SIZE).toByteArray()) 22 | return String(plainText) 23 | } 24 | 25 | fun encrypt(key: String, inputText: String): String { 26 | val cipher = Cipher.getInstance(ALGORITHM) 27 | val iv = generateIv() 28 | cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(hash(key),"AES"), IvParameterSpec(iv)) 29 | val cipherBytes = iv + cipher.doFinal(inputText.toByteArray()) 30 | return Base64.getEncoder().encodeToString(cipherBytes) 31 | } 32 | 33 | private fun generateIv(): ByteArray { 34 | val b = ByteArray(IV_SIZE) 35 | Random().nextBytes(b) 36 | return b 37 | } 38 | 39 | private fun hash(key: String): ByteArray { 40 | val bytes = key.toByteArray() 41 | val md = MessageDigest.getInstance("SHA-256") 42 | return md.digest(bytes) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/plugins/veutify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Route } from 'vue-router' 3 | import { DataOptions } from 'vuetify' 4 | 5 | export type VForm = Vue & { 6 | validate: () => boolean 7 | resetValidation: () => boolean 8 | reset: () => void 9 | } 10 | 11 | export type FormInputType = string | null | File 12 | 13 | export type FormValidationRule = (value: FormInputType) => string | boolean 14 | 15 | export type FormValidationRules = Array 16 | 17 | export interface SelectItem { 18 | text: string 19 | value: string | number | boolean 20 | } 21 | 22 | export interface DatatableFooterProps { 23 | itemsPerPage: number 24 | itemsPerPageOptions: Array 25 | } 26 | 27 | export const DefaultFooterProps: DatatableFooterProps = { 28 | itemsPerPage: 100, 29 | itemsPerPageOptions: [10, 50, 100, 200], 30 | } 31 | 32 | export type ParseParamsResponse = { 33 | options: DataOptions 34 | query: string | null 35 | } 36 | 37 | export const parseFilterOptionsFromParams = ( 38 | route: Route, 39 | options: DataOptions, 40 | ): ParseParamsResponse => { 41 | let query = null 42 | Object.keys(route.query).forEach((value: string) => { 43 | if (value === 'itemsPerPage') { 44 | options.itemsPerPage = parseInt( 45 | (route.query[value] as string) ?? options.itemsPerPage.toString(), 46 | ) 47 | } 48 | 49 | if (value === 'sortBy') { 50 | options.sortBy = [(route.query[value] as string) ?? options.sortBy[0]] 51 | } 52 | 53 | if (value === 'sortDesc') { 54 | options.sortDesc = [!(route.query[value] === 'false')] 55 | } 56 | 57 | if (value === 'page') { 58 | options.page = parseInt( 59 | (route.query[value] as string) ?? options.page.toString(), 60 | ) 61 | } 62 | if (value === 'query') { 63 | query = route.query[value] 64 | } 65 | }) 66 | return { options, query } 67 | } 68 | -------------------------------------------------------------------------------- /web/components/LoadingButton.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 57 | --------------------------------------------------------------------------------