feat: add contact form with SMS OTP verification

This commit is contained in:
badblocks 2025-07-17 19:34:29 -07:00
parent 91b162fb44
commit 3874443c34
No known key found for this signature in database
14 changed files with 729 additions and 54 deletions

5
.env.example Normal file
View 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

View 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>

View file

@ -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>

View file

@ -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=="],

View file

@ -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: {},
},
}); });

View file

@ -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",

View 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.",
});
}
});

View 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.",
});
}
});

View 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
View 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
View 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,
);
}

View 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;
}

View 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
View 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;
}