feat: add contact form with SMS OTP verification
This commit is contained in:
parent
91b162fb44
commit
3874443c34
14 changed files with 729 additions and 54 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NUXT_ANDROID_SMS_GATEWAY_LOGIN=""
|
||||||
|
NUXT_ANDROID_SMS_GATEWAY_PASSWORD=""
|
||||||
|
NUXT_ANDROID_SMS_GATEWAY_URL="" # including http(s)://
|
||||||
|
NUXT_MY_PHONE_NUMBER=""
|
||||||
|
NUXT_SUPER_SECRET_SALT="" #openssl rand -base64 60
|
||||||
262
app/components/ContactForm.vue
Normal file
262
app/components/ContactForm.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
<template>
|
||||||
|
<div class="max-w-xl mx-auto">
|
||||||
|
<form @submit.prevent="sendTextMessage">
|
||||||
|
<!-- Name Input -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<input
|
||||||
|
v-model="userName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your Name"
|
||||||
|
aria-label="Your Name"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:disabled="isMessageSent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Textarea -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<textarea
|
||||||
|
v-model="userMessage"
|
||||||
|
class="textarea textarea-bordered h-32 w-full"
|
||||||
|
placeholder="Your message..."
|
||||||
|
aria-label="Your message..."
|
||||||
|
:disabled="isMessageSent"
|
||||||
|
maxlength="140"
|
||||||
|
pattern="^[\x20-\x7E\n\r]*$"
|
||||||
|
title="Only printable ASCII characters are allowed."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<div
|
||||||
|
class="text-right text-sm mt-1"
|
||||||
|
:class="{ 'text-error': userMessage.length > 140 }"
|
||||||
|
>
|
||||||
|
{{ userMessage.length }} / 140
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone Number and Verification (conditionally enabled) -->
|
||||||
|
<div class="flex flex-col md:flex-row gap-4 mb-4">
|
||||||
|
<div class="form-control w-full md:w-1/2">
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
v-model="phoneNumber"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Your Phone Number"
|
||||||
|
aria-label="Your Phone Number"
|
||||||
|
class="input input-bordered w-full join-item"
|
||||||
|
:disabled="!isNameAndMessageEntered || isCodeSent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary join-item"
|
||||||
|
:disabled="
|
||||||
|
!isNameAndMessageEntered ||
|
||||||
|
isPhoneNumberIncomplete ||
|
||||||
|
isCodeSent ||
|
||||||
|
isSendingCode
|
||||||
|
"
|
||||||
|
@click="sendCode"
|
||||||
|
>
|
||||||
|
<span v-if="isSendingCode" class="loading loading-spinner"></span>
|
||||||
|
Send Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full md:w-1/2">
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
v-model="verificationCode"
|
||||||
|
type="text"
|
||||||
|
placeholder="Verification Code"
|
||||||
|
aria-label="Verification Code"
|
||||||
|
class="input input-bordered join-item w-full"
|
||||||
|
:disabled="!isCodeSent || isVerified"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary join-item"
|
||||||
|
:disabled="!isCodeSent || isVerified || isVerifying"
|
||||||
|
@click="verifyCode"
|
||||||
|
>
|
||||||
|
<span v-if="isVerifying" class="loading loading-spinner"></span>
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit and Status Messages -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div v-if="errorMessage" class="alert alert-error mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isMessageSent" class="alert alert-success mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Text sent successfully! Thanks for reaching out!</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="!isVerified || isSendingMessage || isMessageSent"
|
||||||
|
>
|
||||||
|
<span v-if="isSendingMessage" class="loading loading-spinner"></span>
|
||||||
|
Text Me!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
|
// --- Form State ---
|
||||||
|
const userName = ref("");
|
||||||
|
const phoneNumber = ref("");
|
||||||
|
const verificationCode = ref("");
|
||||||
|
const userMessage = ref("");
|
||||||
|
|
||||||
|
// --- UI State ---
|
||||||
|
const isSendingCode = ref(false);
|
||||||
|
const isCodeSent = ref(false);
|
||||||
|
const isVerifying = ref(false);
|
||||||
|
const isVerified = ref(false);
|
||||||
|
const isSendingMessage = ref(false);
|
||||||
|
const isMessageSent = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
|
||||||
|
// --- Computed Properties ---
|
||||||
|
const isNameAndMessageEntered = computed(() => {
|
||||||
|
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
|
||||||
|
return (
|
||||||
|
userName.value.trim() !== "" &&
|
||||||
|
userMessage.value.trim() !== "" &&
|
||||||
|
userMessage.value.length <= 140 &&
|
||||||
|
printableAsciiRegex.test(userMessage.value)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPhoneNumberIncomplete = computed(() => {
|
||||||
|
// Count only the digits in the phone number
|
||||||
|
const digitCount = (phoneNumber.value.match(/\d/g) || []).length;
|
||||||
|
return digitCount < 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Functions ---
|
||||||
|
const clearError = () => {
|
||||||
|
errorMessage.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
clearError();
|
||||||
|
if (!phoneNumber.value) {
|
||||||
|
errorMessage.value = "Please enter a valid phone number.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSendingCode.value = true;
|
||||||
|
try {
|
||||||
|
const response = await $fetch("/api/send-otp", {
|
||||||
|
method: "POST",
|
||||||
|
body: { phoneNumber: phoneNumber.value },
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
isCodeSent.value = true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to send code. Please try again.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value =
|
||||||
|
error.data?.statusMessage || "An unexpected error occurred.";
|
||||||
|
} finally {
|
||||||
|
isSendingCode.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyCode = async () => {
|
||||||
|
clearError();
|
||||||
|
if (!verificationCode.value) {
|
||||||
|
errorMessage.value = "Please enter the verification code.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isVerifying.value = true;
|
||||||
|
try {
|
||||||
|
const response = await $fetch("/api/verify-otp", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
code: verificationCode.value,
|
||||||
|
phoneNumber: phoneNumber.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
isVerified.value = true;
|
||||||
|
errorMessage.value = ""; // Clear error on success
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value =
|
||||||
|
error.data?.statusMessage || "An unexpected error occurred.";
|
||||||
|
isVerified.value = false;
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTextMessage = async () => {
|
||||||
|
clearError();
|
||||||
|
if (!isNameAndMessageEntered.value) {
|
||||||
|
errorMessage.value =
|
||||||
|
"Please fill out your name and a valid message before sending.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSendingMessage.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch("/api/send-message", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: userName.value,
|
||||||
|
message: userMessage.value,
|
||||||
|
phoneNumber: phoneNumber.value,
|
||||||
|
code: verificationCode.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
isMessageSent.value = true;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to send message. Please try again later.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value =
|
||||||
|
error.data?.statusMessage ||
|
||||||
|
"An unexpected error occurred while sending your message.";
|
||||||
|
} finally {
|
||||||
|
isSendingMessage.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -121,52 +121,7 @@
|
||||||
<section id="contact" class="py-20 bg-base-100">
|
<section id="contact" class="py-20 bg-base-100">
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<h2 class="text-4xl font-bold text-center mb-12">Get In Touch</h2>
|
<h2 class="text-4xl font-bold text-center mb-12">Get In Touch</h2>
|
||||||
<div class="max-w-xl mx-auto">
|
<ContactForm />
|
||||||
<form>
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Your Name"
|
|
||||||
aria-label="Your Name"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row gap-4 mb-4">
|
|
||||||
<div class="form-control w-full md:w-1/2">
|
|
||||||
<div class="join w-full">
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
placeholder="Your Phone Number"
|
|
||||||
aria-label="Your Phone Number"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-secondary join-item">Send Code</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control w-full md:w-1/2">
|
|
||||||
<div class="join w-full">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Verification Code"
|
|
||||||
aria-label="Verification Code"
|
|
||||||
class="input input-bordered join-item w-full"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-secondary join-item">Verify</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<textarea
|
|
||||||
class="textarea textarea-bordered h-32 w-full"
|
|
||||||
placeholder="Your message..."
|
|
||||||
aria-label="Your message..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<button class="btn btn-primary w-full">Text Me!</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,13 +136,12 @@ onMounted(() => {
|
||||||
const typedOptions = {
|
const typedOptions = {
|
||||||
strings: [
|
strings: [
|
||||||
"an aspiring Software Engineer.",
|
"an aspiring Software Engineer.",
|
||||||
"a Web Developer.",
|
"a self-taught Web Developer with experience.",
|
||||||
"a Backend Magician.",
|
"a Full-Stack Magician.",
|
||||||
"a UI/UX Problem Solver.",
|
"a UI/UX Problem Solver.",
|
||||||
"an Accessibility Advocate.",
|
"a Web Accessibility Advocate.",
|
||||||
"an Open Source Contributor.",
|
"an Open Source Contributor.",
|
||||||
"a Collaborative Teammate.",
|
"a Collaborative Team Player.",
|
||||||
"a Creative Coder.",
|
|
||||||
],
|
],
|
||||||
typeSpeed: 60,
|
typeSpeed: 60,
|
||||||
backSpeed: 40,
|
backSpeed: 40,
|
||||||
|
|
@ -208,7 +162,7 @@ onMounted(() => {
|
||||||
|
|
||||||
$sr.reveal("#projects .card", { interval: 100 });
|
$sr.reveal("#projects .card", { interval: 100 });
|
||||||
|
|
||||||
$sr.reveal("#contact form", { delay: 300 });
|
$sr.reveal("#contact .max-w-xl", { delay: 300 });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
15
bun.lock
15
bun.lock
|
|
@ -19,6 +19,7 @@
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nuxt": "^4.0.0",
|
"nuxt": "^4.0.0",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"scrollreveal": "^4.0.9",
|
"scrollreveal": "^4.0.9",
|
||||||
"typed.js": "^2.1.0",
|
"typed.js": "^2.1.0",
|
||||||
|
|
@ -322,6 +323,16 @@
|
||||||
|
|
||||||
"@nuxtjs/mdc": ["@nuxtjs/mdc@0.17.0", "", { "dependencies": { "@nuxt/kit": "^3.16.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "@shikijs/transformers": "^3.3.0", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@vue/compiler-core": "^3.5.13", "consola": "^3.4.2", "debug": "4.4.0", "defu": "^6.1.4", "destr": "^2.0.5", "detab": "^3.0.2", "github-slugger": "^2.0.0", "hast-util-format": "^1.1.0", "hast-util-to-mdast": "^10.1.2", "hast-util-to-string": "^3.0.1", "mdast-util-to-hast": "^13.2.0", "micromark-util-sanitize-uri": "^2.0.1", "parse5": "^7.3.0", "pathe": "^2.0.3", "property-information": "^7.0.0", "rehype-external-links": "^3.0.0", "rehype-minify-whitespace": "^6.0.2", "rehype-raw": "^7.0.0", "rehype-remark": "^10.0.1", "rehype-slug": "^6.0.0", "rehype-sort-attribute-values": "^5.0.1", "rehype-sort-attributes": "^5.0.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "remark-mdc": "v3.6.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "scule": "^1.3.0", "shiki": "^3.3.0", "ufo": "^1.6.1", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.0.0", "unwasm": "^0.3.9", "vfile": "^6.0.3" } }, "sha512-5HFJ2Xatl4oSfEZuYRJhzYhVHNvb31xc9Tu/qfXpRIWeQsQphqjaV3wWB5VStZYEHpTw1i6Hzyz/ojQZVl4qPg=="],
|
"@nuxtjs/mdc": ["@nuxtjs/mdc@0.17.0", "", { "dependencies": { "@nuxt/kit": "^3.16.2", "@shikijs/langs": "^3.3.0", "@shikijs/themes": "^3.3.0", "@shikijs/transformers": "^3.3.0", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@vue/compiler-core": "^3.5.13", "consola": "^3.4.2", "debug": "4.4.0", "defu": "^6.1.4", "destr": "^2.0.5", "detab": "^3.0.2", "github-slugger": "^2.0.0", "hast-util-format": "^1.1.0", "hast-util-to-mdast": "^10.1.2", "hast-util-to-string": "^3.0.1", "mdast-util-to-hast": "^13.2.0", "micromark-util-sanitize-uri": "^2.0.1", "parse5": "^7.3.0", "pathe": "^2.0.3", "property-information": "^7.0.0", "rehype-external-links": "^3.0.0", "rehype-minify-whitespace": "^6.0.2", "rehype-raw": "^7.0.0", "rehype-remark": "^10.0.1", "rehype-slug": "^6.0.0", "rehype-sort-attribute-values": "^5.0.1", "rehype-sort-attributes": "^5.0.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "remark-mdc": "v3.6.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "scule": "^1.3.0", "shiki": "^3.3.0", "ufo": "^1.6.1", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.0.0", "unwasm": "^0.3.9", "vfile": "^6.0.3" } }, "sha512-5HFJ2Xatl4oSfEZuYRJhzYhVHNvb31xc9Tu/qfXpRIWeQsQphqjaV3wWB5VStZYEHpTw1i6Hzyz/ojQZVl4qPg=="],
|
||||||
|
|
||||||
|
"@otplib/core": ["@otplib/core@12.0.1", "", {}, "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="],
|
||||||
|
|
||||||
|
"@otplib/plugin-crypto": ["@otplib/plugin-crypto@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1" } }, "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g=="],
|
||||||
|
|
||||||
|
"@otplib/plugin-thirty-two": ["@otplib/plugin-thirty-two@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "thirty-two": "^1.0.2" } }, "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA=="],
|
||||||
|
|
||||||
|
"@otplib/preset-default": ["@otplib/preset-default@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ=="],
|
||||||
|
|
||||||
|
"@otplib/preset-v11": ["@otplib/preset-v11@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg=="],
|
||||||
|
|
||||||
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.77.2", "", { "os": "android", "cpu": "arm64" }, "sha512-mYTzTLmuIz8bg7DyXu7IL3cOy28jfuiTRff4pZcQCRhWgU4LxAq2WmLw69/XvFE1zTJGVRc0nbOoxVw2o4FHIw=="],
|
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.77.2", "", { "os": "android", "cpu": "arm64" }, "sha512-mYTzTLmuIz8bg7DyXu7IL3cOy28jfuiTRff4pZcQCRhWgU4LxAq2WmLw69/XvFE1zTJGVRc0nbOoxVw2o4FHIw=="],
|
||||||
|
|
||||||
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.77.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aPj/O0U66GY5Wcyd8Vn6ypkfWPEX7LZRyfUOpDNruGujM5N/i2Q9jJZfIzApMIBZXpNtUpD+RKpOaI5xJPiVdw=="],
|
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.77.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aPj/O0U66GY5Wcyd8Vn6ypkfWPEX7LZRyfUOpDNruGujM5N/i2Q9jJZfIzApMIBZXpNtUpD+RKpOaI5xJPiVdw=="],
|
||||||
|
|
@ -1740,6 +1751,8 @@
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"otplib": ["otplib@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/preset-default": "^12.0.1", "@otplib/preset-v11": "^12.0.1" } }, "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg=="],
|
||||||
|
|
||||||
"oxc-minify": ["oxc-minify@0.77.2", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.77.2", "@oxc-minify/binding-darwin-arm64": "0.77.2", "@oxc-minify/binding-darwin-x64": "0.77.2", "@oxc-minify/binding-freebsd-x64": "0.77.2", "@oxc-minify/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-minify/binding-linux-arm-musleabihf": "0.77.2", "@oxc-minify/binding-linux-arm64-gnu": "0.77.2", "@oxc-minify/binding-linux-arm64-musl": "0.77.2", "@oxc-minify/binding-linux-riscv64-gnu": "0.77.2", "@oxc-minify/binding-linux-s390x-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-musl": "0.77.2", "@oxc-minify/binding-wasm32-wasi": "0.77.2", "@oxc-minify/binding-win32-arm64-msvc": "0.77.2", "@oxc-minify/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+/1Gmx5K7t4beud4ZRYFo5Egkk0Op0qRAWPORWM0EnU1P343PxvJlpSt6QendAH+8XVk2urgidPKzkub53+GEg=="],
|
"oxc-minify": ["oxc-minify@0.77.2", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.77.2", "@oxc-minify/binding-darwin-arm64": "0.77.2", "@oxc-minify/binding-darwin-x64": "0.77.2", "@oxc-minify/binding-freebsd-x64": "0.77.2", "@oxc-minify/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-minify/binding-linux-arm-musleabihf": "0.77.2", "@oxc-minify/binding-linux-arm64-gnu": "0.77.2", "@oxc-minify/binding-linux-arm64-musl": "0.77.2", "@oxc-minify/binding-linux-riscv64-gnu": "0.77.2", "@oxc-minify/binding-linux-s390x-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-gnu": "0.77.2", "@oxc-minify/binding-linux-x64-musl": "0.77.2", "@oxc-minify/binding-wasm32-wasi": "0.77.2", "@oxc-minify/binding-win32-arm64-msvc": "0.77.2", "@oxc-minify/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+/1Gmx5K7t4beud4ZRYFo5Egkk0Op0qRAWPORWM0EnU1P343PxvJlpSt6QendAH+8XVk2urgidPKzkub53+GEg=="],
|
||||||
|
|
||||||
"oxc-parser": ["oxc-parser@0.77.2", "", { "dependencies": { "@oxc-project/types": "^0.77.2" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.77.2", "@oxc-parser/binding-darwin-arm64": "0.77.2", "@oxc-parser/binding-darwin-x64": "0.77.2", "@oxc-parser/binding-freebsd-x64": "0.77.2", "@oxc-parser/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-parser/binding-linux-arm-musleabihf": "0.77.2", "@oxc-parser/binding-linux-arm64-gnu": "0.77.2", "@oxc-parser/binding-linux-arm64-musl": "0.77.2", "@oxc-parser/binding-linux-riscv64-gnu": "0.77.2", "@oxc-parser/binding-linux-s390x-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-musl": "0.77.2", "@oxc-parser/binding-wasm32-wasi": "0.77.2", "@oxc-parser/binding-win32-arm64-msvc": "0.77.2", "@oxc-parser/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+FMfYsACcAcoeVJfvewk9FeSVXO2maiv78hE1zHzQPwLB7QTdsharc08HnBa0bUPl9D1dfzs20wPGmLLY2HYtg=="],
|
"oxc-parser": ["oxc-parser@0.77.2", "", { "dependencies": { "@oxc-project/types": "^0.77.2" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.77.2", "@oxc-parser/binding-darwin-arm64": "0.77.2", "@oxc-parser/binding-darwin-x64": "0.77.2", "@oxc-parser/binding-freebsd-x64": "0.77.2", "@oxc-parser/binding-linux-arm-gnueabihf": "0.77.2", "@oxc-parser/binding-linux-arm-musleabihf": "0.77.2", "@oxc-parser/binding-linux-arm64-gnu": "0.77.2", "@oxc-parser/binding-linux-arm64-musl": "0.77.2", "@oxc-parser/binding-linux-riscv64-gnu": "0.77.2", "@oxc-parser/binding-linux-s390x-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-gnu": "0.77.2", "@oxc-parser/binding-linux-x64-musl": "0.77.2", "@oxc-parser/binding-wasm32-wasi": "0.77.2", "@oxc-parser/binding-win32-arm64-msvc": "0.77.2", "@oxc-parser/binding-win32-x64-msvc": "0.77.2" } }, "sha512-+FMfYsACcAcoeVJfvewk9FeSVXO2maiv78hE1zHzQPwLB7QTdsharc08HnBa0bUPl9D1dfzs20wPGmLLY2HYtg=="],
|
||||||
|
|
@ -2150,6 +2163,8 @@
|
||||||
|
|
||||||
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
||||||
|
|
||||||
|
"thirty-two": ["thirty-two@1.0.2", "", {}, "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA=="],
|
||||||
|
|
||||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,14 @@ export default defineNuxtConfig({
|
||||||
"@nuxt/test-utils",
|
"@nuxt/test-utils",
|
||||||
"@nuxt/ui",
|
"@nuxt/ui",
|
||||||
],
|
],
|
||||||
});
|
runtimeConfig: {
|
||||||
|
androidSmsGatewayUrl: process.env.NUXT_ANDROID_SMS_GATEWAY_URL,
|
||||||
|
androidSmsGatewayLogin: process.env.NUXT_ANDROID_SMS_GATEWAY_LOGIN,
|
||||||
|
androidSmsGatewayPassword: process.env.NUXT_ANDROID_SMS_GATEWAY_PASSWORD,
|
||||||
|
myPhoneNumber: process.env.NUXT_MY_PHONE_NUMBER,
|
||||||
|
superSecretSalt: process.env.NUXT_SUPER_SECRET_SALT,
|
||||||
|
|
||||||
|
// Keys within public, will be also exposed to the client-side
|
||||||
|
public: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "nuxt dev",
|
"dev": "bun --env-file=.env nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nuxt": "^4.0.0",
|
"nuxt": "^4.0.0",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"scrollreveal": "^4.0.9",
|
"scrollreveal": "^4.0.9",
|
||||||
"typed.js": "^2.1.0",
|
"typed.js": "^2.1.0",
|
||||||
|
|
|
||||||
97
server/api/send-message.post.js
Normal file
97
server/api/send-message.post.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { verifyTOTP } from "../utils/totp";
|
||||||
|
import { createSmsGatewayClient } from "../lib/sms-gateway";
|
||||||
|
import { isRateLimited, recordSubmission } from "../utils/rate-limiter.js";
|
||||||
|
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
message: userMessage,
|
||||||
|
phoneNumber: rawPhoneNumber,
|
||||||
|
code,
|
||||||
|
} = await readBody(event);
|
||||||
|
|
||||||
|
let phoneNumber;
|
||||||
|
try {
|
||||||
|
phoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
|
||||||
|
} catch (error) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input Validation ---
|
||||||
|
if (!name || !userMessage || !code) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "All fields are required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent abuse by checking rate limit before doing anything
|
||||||
|
if (isRateLimited(phoneNumber)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage:
|
||||||
|
"You have already sent a message within the last week. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessage.length > 140) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Message cannot be longer than 140 characters.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
|
||||||
|
if (!printableAsciiRegex.test(userMessage)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Message contains non-ASCII or non-printable characters.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server Configuration Check ---
|
||||||
|
if (!config.myPhoneNumber || !config.superSecretSalt) {
|
||||||
|
console.error(
|
||||||
|
"Server is not fully configured. MY_PHONE_NUMBER and SUPER_SECRET_SALT are required.",
|
||||||
|
);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "A server configuration error occurred.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Verification ---
|
||||||
|
const isVerified = verifyTOTP(phoneNumber, config.superSecretSalt, code);
|
||||||
|
if (!isVerified) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage:
|
||||||
|
"Your verification code is invalid or has expired. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Send Message ---
|
||||||
|
try {
|
||||||
|
const api = createSmsGatewayClient(config);
|
||||||
|
const finalMessage = `New message from ${name} ( ${phoneNumber} ) via your portfolio:\n\n"${userMessage}"`;
|
||||||
|
const message = {
|
||||||
|
phoneNumbers: [config.myPhoneNumber],
|
||||||
|
message: finalMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await api.send(message);
|
||||||
|
|
||||||
|
// On success, record the submission time to start the rate-limiting period.
|
||||||
|
recordSubmission(phoneNumber);
|
||||||
|
|
||||||
|
return { success: true, messageId: state.id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send message:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Failed to send message.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
56
server/api/send-otp.post.js
Normal file
56
server/api/send-otp.post.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { generateTOTP } from "../utils/totp";
|
||||||
|
import { createSmsGatewayClient } from "../lib/sms-gateway";
|
||||||
|
import { isRateLimited } from "../utils/rate-limiter.js";
|
||||||
|
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const { phoneNumber: rawPhoneNumber } = await readBody(event);
|
||||||
|
let normalizedPhoneNumber;
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
|
||||||
|
} catch (error) {
|
||||||
|
// The validator throws an error with a user-friendly message.
|
||||||
|
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent abuse by checking rate limit before sending an SMS
|
||||||
|
if (isRateLimited(normalizedPhoneNumber)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage:
|
||||||
|
"You have already sent a message within the last week. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.superSecretSalt) {
|
||||||
|
console.error("SUPER_SECRET_SALT is not configured on the server.");
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "A server configuration error occurred.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = createSmsGatewayClient(config);
|
||||||
|
const otp = generateTOTP(normalizedPhoneNumber, config.superSecretSalt);
|
||||||
|
const step_min = Math.floor(getTOTPstep() / 60);
|
||||||
|
const step_sec = getTOTPstep() % 60;
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
phoneNumbers: [normalizedPhoneNumber],
|
||||||
|
message: `${otp} is your verification code. This code is valid for ${step_min}m${step_sec}s.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await api.send(message);
|
||||||
|
return { success: true, messageId: state.id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send OTP:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage:
|
||||||
|
"An error occurred while trying to send the verification code.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
59
server/api/verify-otp.post.js
Normal file
59
server/api/verify-otp.post.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { verifyTOTP } from "../utils/totp";
|
||||||
|
import { isRateLimited } from "../utils/rate-limiter.js";
|
||||||
|
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const { phoneNumber: rawPhoneNumber, code } = await readBody(event);
|
||||||
|
let normalizedPhoneNumber;
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
|
||||||
|
} catch (error) {
|
||||||
|
// The validator throws an error with a user-friendly message.
|
||||||
|
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Verification code is required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent abuse by checking rate limit before doing anything
|
||||||
|
if (isRateLimited(normalizedPhoneNumber)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage:
|
||||||
|
"You have already sent a message within the last week. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for necessary server configuration.
|
||||||
|
if (!config.superSecretSalt) {
|
||||||
|
console.error("SUPER_SECRET_SALT is not configured on the server.");
|
||||||
|
// This is an internal server error, so we don't expose details to the client.
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "A server configuration error occurred.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = verifyTOTP(
|
||||||
|
normalizedPhoneNumber,
|
||||||
|
config.superSecretSalt,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// In a stateful app, one might set a session cookie here.
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
// The code is incorrect or has expired.
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401, // Unauthorized
|
||||||
|
statusMessage: "Invalid or expired verification code.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
45
server/lib/http-client.js
Normal file
45
server/lib/http-client.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* A simple HTTP client wrapper around the global fetch function.
|
||||||
|
* This is used by the android-sms-gateway client.
|
||||||
|
*/
|
||||||
|
export const httpFetchClient = {
|
||||||
|
get: async (url, headers) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
console.error("Gateway GET error:", errorBody);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
post: async (url, body, headers) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
console.error("Gateway POST error:", errorBody);
|
||||||
|
throw new Error(
|
||||||
|
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
delete: async (url, headers) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
console.error("Gateway DELETE error:", errorBody);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
42
server/lib/sms-gateway.js
Normal file
42
server/lib/sms-gateway.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Client from "android-sms-gateway";
|
||||||
|
import { httpFetchClient } from "./http-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and configures an instance of the android-sms-gateway client.
|
||||||
|
* It centralizes the client instantiation and ensures that all necessary
|
||||||
|
* configuration for the gateway is present.
|
||||||
|
*
|
||||||
|
* @param {object} config The runtime configuration object from `useRuntimeConfig`.
|
||||||
|
* It should contain `androidSmsGatewayLogin`, `androidSmsGatewayPassword`,
|
||||||
|
* and `androidSmsGatewayUrl`.
|
||||||
|
* @returns {Client} A configured instance of the SMS gateway client.
|
||||||
|
* @throws {Error} If the required gateway configuration is missing, prompting
|
||||||
|
* a 500-level error in the calling API endpoint.
|
||||||
|
*/
|
||||||
|
export function createSmsGatewayClient(config) {
|
||||||
|
const {
|
||||||
|
androidSmsGatewayLogin,
|
||||||
|
androidSmsGatewayPassword,
|
||||||
|
androidSmsGatewayUrl,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!androidSmsGatewayLogin ||
|
||||||
|
!androidSmsGatewayPassword ||
|
||||||
|
!androidSmsGatewayUrl
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"SMS Gateway service is not configured. Missing required environment variables for the gateway.",
|
||||||
|
);
|
||||||
|
// This indicates a critical server misconfiguration. The calling API endpoint
|
||||||
|
// should handle this and return a generic 500 error to the client.
|
||||||
|
throw new Error("Server is not configured for sending SMS.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Client(
|
||||||
|
androidSmsGatewayLogin,
|
||||||
|
androidSmsGatewayPassword,
|
||||||
|
httpFetchClient,
|
||||||
|
androidSmsGatewayUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
36
server/utils/phone-validator.js
Normal file
36
server/utils/phone-validator.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Normalizes and validates a phone number string based on specific rules.
|
||||||
|
*
|
||||||
|
* The normalization process is as follows:
|
||||||
|
* 1. All non-digit characters are stripped from the input string.
|
||||||
|
* 2. If the resulting number has a leading '1', it is removed.
|
||||||
|
*
|
||||||
|
* After normalization, the function validates that the resulting number
|
||||||
|
* is exactly 10 digits long.
|
||||||
|
*
|
||||||
|
* @param {string} rawPhoneNumber The raw phone number string provided by the user.
|
||||||
|
* @returns {string} The normalized, 10-digit phone number.
|
||||||
|
* @throws {Error} Throws an error if the phone number is invalid after normalization,
|
||||||
|
* allowing API endpoints to catch it and return a proper HTTP status.
|
||||||
|
*/
|
||||||
|
export function normalizeAndValidatePhoneNumber(rawPhoneNumber) {
|
||||||
|
if (!rawPhoneNumber || typeof rawPhoneNumber !== "string") {
|
||||||
|
throw new Error("Phone number is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Strip all non-digit characters.
|
||||||
|
const digitsOnly = rawPhoneNumber.replace(/\D/g, "");
|
||||||
|
|
||||||
|
// 2. If the number starts with a '1', remove it.
|
||||||
|
let numberToValidate = digitsOnly;
|
||||||
|
if (numberToValidate.startsWith("1")) {
|
||||||
|
numberToValidate = numberToValidate.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if the resulting number is exactly 10 digits long.
|
||||||
|
if (numberToValidate.length !== 10) {
|
||||||
|
throw new Error("Please provide a valid 10-digit phone number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return numberToValidate;
|
||||||
|
}
|
||||||
33
server/utils/rate-limiter.js
Normal file
33
server/utils/rate-limiter.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// A shared, in-memory store for tracking submission timestamps.
|
||||||
|
const submissionTimestamps = new Map();
|
||||||
|
|
||||||
|
// The rate-limiting period (1 week in milliseconds).
|
||||||
|
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given phone number is currently rate-limited.
|
||||||
|
* A phone number is considered rate-limited if a successful submission
|
||||||
|
* was recorded within the last week.
|
||||||
|
*
|
||||||
|
* @param {string} phoneNumber The phone number to check.
|
||||||
|
* @returns {boolean} True if the number is rate-limited, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isRateLimited(phoneNumber) {
|
||||||
|
const lastSubmissionTime = submissionTimestamps.get(phoneNumber);
|
||||||
|
if (!lastSubmissionTime) {
|
||||||
|
return false; // Not in the map, so not rate-limited.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the time elapsed since the last submission is less than one week.
|
||||||
|
return Date.now() - lastSubmissionTime < ONE_WEEK_IN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a successful submission for a given phone number by setting
|
||||||
|
* the current timestamp. This will start the 1-week rate-limiting period.
|
||||||
|
*
|
||||||
|
* @param {string} phoneNumber The phone number to record the submission for.
|
||||||
|
*/
|
||||||
|
export function recordSubmission(phoneNumber) {
|
||||||
|
submissionTimestamps.set(phoneNumber, Date.now());
|
||||||
|
}
|
||||||
60
server/utils/totp.js
Normal file
60
server/utils/totp.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { authenticator } from "otplib";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
// These settings must be consistent between generation and verification.
|
||||||
|
authenticator.options = {
|
||||||
|
step: 60, // OTP is valid for 1 minute per window
|
||||||
|
window: [5, 1], // Allow tokens from 5 previous and 1 future time-steps.
|
||||||
|
digits: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a stable, stateless secret for a user from their phone number
|
||||||
|
* and a global salt.
|
||||||
|
* @param {string} phoneNumber The user's phone number.
|
||||||
|
* @param {string} salt The global super secret salt.
|
||||||
|
* @returns {string} A hex-encoded secret string.
|
||||||
|
*/
|
||||||
|
function getUserSecret(phoneNumber, salt) {
|
||||||
|
if (!phoneNumber || !salt) {
|
||||||
|
throw new Error(
|
||||||
|
"Phone number and salt are required to generate a user secret.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(phoneNumber + salt)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Time-based One-Time Password (TOTP) for a given phone number.
|
||||||
|
* @param {string} phoneNumber The user's phone number.
|
||||||
|
* @param {string} salt The global super secret salt.
|
||||||
|
* @returns {string} The generated 6-digit OTP.
|
||||||
|
*/
|
||||||
|
export function generateTOTP(phoneNumber, salt) {
|
||||||
|
const userSecret = getUserSecret(phoneNumber, salt);
|
||||||
|
return authenticator.generate(userSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a TOTP token submitted by a user.
|
||||||
|
* @param {string} phoneNumber The user's phone number.
|
||||||
|
* @param {string} salt The global super secret salt.
|
||||||
|
* @param {string} token The 6-digit OTP token submitted by the user.
|
||||||
|
* @returns {boolean} True if the token is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function verifyTOTP(phoneNumber, salt, token) {
|
||||||
|
const userSecret = getUserSecret(phoneNumber, salt);
|
||||||
|
// The `verify` method checks the token against the current and adjacent
|
||||||
|
// time-steps, as configured in the options.
|
||||||
|
return authenticator.verify({ token, secret: userSecret });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current TOTP step in seconds.
|
||||||
|
* @returns {number} The current TOTP step in seconds.
|
||||||
|
*/
|
||||||
|
export function getTOTPstep() {
|
||||||
|
return authenticator.options.step;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue