dev work
This commit is contained in:
parent
6e7977997e
commit
1707e2a396
21 changed files with 289 additions and 198 deletions
|
|
@ -3,13 +3,15 @@ import { z } from "astro/zod";
|
|||
import type { ActionAPIContext } from "astro:actions";
|
||||
import validator from "validator";
|
||||
import SmsClient from "@lib/SmsGatewayClient.ts";
|
||||
import Otp, { verifyOtp } from "@lib/Otp.ts";
|
||||
import Otp, { verifyOtp, normalizePhone } from "@lib/Otp.ts";
|
||||
import { createCap } from "@lib/CapAdapter";
|
||||
import {
|
||||
OTP_SUPER_SECRET_SALT,
|
||||
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
|
||||
} from "astro:env/server";
|
||||
|
||||
const MAX_OTP_VERIFY_ATTEMPTS = 5;
|
||||
|
||||
const isValidMobilePhone: [(data: string) => any, { message: string }] = [
|
||||
(value: string) =>
|
||||
validator.isMobilePhone(value, ["en-US", "en-CA"]) &&
|
||||
|
|
@ -39,7 +41,11 @@ const captcha_input = z.string().trim().nonempty();
|
|||
|
||||
const sendOtpAction = z.object({
|
||||
action: z.literal("send_otp"),
|
||||
name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform(stripDisallowedCharacters)
|
||||
.pipe(z.string().min(5).max(32)),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
|
|
@ -47,11 +53,16 @@ const sendOtpAction = z.object({
|
|||
msg: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(25)
|
||||
.max(512)
|
||||
.transform(stripDisallowedCharacters)
|
||||
.refine(...noYelling)
|
||||
.refine(...noExcessiveRepetitions),
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(25)
|
||||
.max(512)
|
||||
|
||||
.refine(...noYelling)
|
||||
.refine(...noExcessiveRepetitions),
|
||||
),
|
||||
captcha: captcha_input,
|
||||
});
|
||||
|
||||
|
|
@ -63,6 +74,7 @@ const sendMsgAction = z.object({
|
|||
|
||||
const resetAction = z.object({
|
||||
action: z.literal("reset"),
|
||||
captcha: captcha_input,
|
||||
});
|
||||
|
||||
const formAction = z.discriminatedUnion("action", [
|
||||
|
|
@ -73,7 +85,10 @@ const formAction = z.discriminatedUnion("action", [
|
|||
|
||||
const submitActionDefinition = {
|
||||
input: formAction,
|
||||
handler: async (input: any, context: ActionAPIContext) => {
|
||||
handler: async (
|
||||
input: z.infer<typeof formAction>,
|
||||
context: ActionAPIContext,
|
||||
) => {
|
||||
if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) {
|
||||
throw new ActionError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
|
@ -83,12 +98,10 @@ const submitActionDefinition = {
|
|||
|
||||
const cap = createCap(context.session ?? null);
|
||||
|
||||
if (
|
||||
!(
|
||||
/^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) &&
|
||||
(await cap.validateToken(input.captcha))
|
||||
)
|
||||
) {
|
||||
if (!(
|
||||
/^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(input.captcha) &&
|
||||
(await cap.validateToken(input.captcha))
|
||||
)) {
|
||||
throw new ActionError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid Captcha Token.",
|
||||
|
|
@ -96,7 +109,19 @@ const submitActionDefinition = {
|
|||
}
|
||||
|
||||
if (input.action === "send_otp") {
|
||||
const { name, phone, msg } = input;
|
||||
const { name, msg } = input;
|
||||
const phone = normalizePhone(input.phone);
|
||||
|
||||
if (
|
||||
Otp.isRateLimitedGlobally() ||
|
||||
Otp.isRateLimitedForOtp(phone) ||
|
||||
Otp.isRateLimitedForMsgs(phone)
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many requests. Try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT);
|
||||
const stepSeconds = Otp.getOtpStep();
|
||||
|
|
@ -107,6 +132,9 @@ const submitActionDefinition = {
|
|||
remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "."
|
||||
}`;
|
||||
|
||||
Otp.recordOtpRequest(phone);
|
||||
Otp.recordGlobalOtpSend();
|
||||
|
||||
const result = await new SmsClient().sendSMS(phone, message);
|
||||
|
||||
if (result.success) {
|
||||
|
|
@ -118,38 +146,59 @@ const submitActionDefinition = {
|
|||
nextAction: "send_msg",
|
||||
};
|
||||
} else {
|
||||
console.error("OTP SMS send failed:", result.message);
|
||||
throw new ActionError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Verification code failed to send: " + result.message,
|
||||
message: "Verification code failed to send. Try again later.",
|
||||
});
|
||||
}
|
||||
} else if (input.action === "send_msg") {
|
||||
const { otp } = input;
|
||||
const name = await context.session?.get("name");
|
||||
const phone = await context.session?.get("phone");
|
||||
const raw_phone = await context.session?.get("phone");
|
||||
const msg = await context.session?.get("msg");
|
||||
|
||||
if (!name || !otp || !msg || !phone) {
|
||||
if (!name || !otp || !msg || !raw_phone) {
|
||||
throw new ActionError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing required fields.",
|
||||
});
|
||||
}
|
||||
|
||||
const phone = normalizePhone(raw_phone);
|
||||
|
||||
if (Otp.isRateLimitedForOtp(phone) || Otp.isRateLimitedForMsgs(phone)) {
|
||||
throw new ActionError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many requests. Try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp);
|
||||
if (!isVerified) {
|
||||
const attempts = ((await context.session?.get("otpAttempts")) ?? 0) + 1;
|
||||
if (attempts >= MAX_OTP_VERIFY_ATTEMPTS) {
|
||||
context.session?.delete("phone");
|
||||
context.session?.delete("name");
|
||||
context.session?.delete("msg");
|
||||
context.session?.delete("otpAttempts");
|
||||
return {
|
||||
nextAction: "send_otp",
|
||||
error: "Too many incorrect codes. Please start over.",
|
||||
field: "otp",
|
||||
};
|
||||
}
|
||||
context.session?.set("otpAttempts", attempts);
|
||||
return {
|
||||
nextAction: "send_msg",
|
||||
error: "Invalid or expired verification code.",
|
||||
field: "otp",
|
||||
};
|
||||
// throw new ActionError({
|
||||
// code: "BAD_REQUEST",
|
||||
// message: "Invalid or expired verification code.",
|
||||
// });
|
||||
}
|
||||
|
||||
const message = `Web message from ${name} ( ${phone} ):\n\n${msg}`;
|
||||
const message = `Web message from ${name} (${phone}):\n\n${msg}`;
|
||||
|
||||
Otp.recordMsgSubmission(phone);
|
||||
|
||||
const smsClient = new SmsClient();
|
||||
const result = await smsClient.sendSMS(
|
||||
|
|
@ -158,17 +207,17 @@ const submitActionDefinition = {
|
|||
);
|
||||
|
||||
if (result.success) {
|
||||
Otp.recordMsgSubmission(phone);
|
||||
|
||||
context.session?.delete("phone");
|
||||
context.session?.delete("name");
|
||||
context.session?.delete("msg");
|
||||
context.session?.delete("otpAttempts");
|
||||
|
||||
return {
|
||||
nextAction: "complete",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Contact SMS send failed:", result.message);
|
||||
throw new ActionError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Message failed to send.",
|
||||
|
|
@ -177,6 +226,7 @@ const submitActionDefinition = {
|
|||
context.session?.delete("phone");
|
||||
context.session?.delete("name");
|
||||
context.session?.delete("msg");
|
||||
context.session?.delete("otpAttempts");
|
||||
|
||||
return {
|
||||
nextAction: "send_otp",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Cap, { type ChallengeData } from "@cap.js/server";
|
||||
import type { AstroSession } from "astro";
|
||||
|
||||
export function createCap(session: AstroSession<any> | null) {
|
||||
export function createCap(session: AstroSession | null) {
|
||||
if (!session) {
|
||||
throw new Error("Session context is required");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
import { ofetch } from "ofetch";
|
||||
import { ProxyAgent } from "undici";
|
||||
|
||||
const wireguardDispatcher = new ProxyAgent("http://wireguard:8888");
|
||||
const TIMEOUT = 5000;
|
||||
|
||||
const httpFetchClient = {
|
||||
get: async (url: string, headers: Record<string, string>) => {
|
||||
const response = await ofetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
dispatcher: wireguardDispatcher,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
@ -20,8 +14,6 @@ const httpFetchClient = {
|
|||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
dispatcher: wireguardDispatcher,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
@ -31,8 +23,6 @@ const httpFetchClient = {
|
|||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
dispatcher: wireguardDispatcher,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
@ -42,8 +32,6 @@ const httpFetchClient = {
|
|||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
dispatcher: wireguardDispatcher,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
@ -52,8 +40,6 @@ const httpFetchClient = {
|
|||
const response = await ofetch(url, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
dispatcher: wireguardDispatcher,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,44 @@
|
|||
import { authenticator } from "otplib";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const submissionTimestamps = new Map();
|
||||
const otpRequestTimestamps = new Map();
|
||||
const submissionTimestamps = new Map<string, number[]>();
|
||||
const otpRequestTimestamps = new Map<string, number[]>();
|
||||
const ONE_WEEK_IN_MS: number = 7 * 24 * 60 * 60 * 1000;
|
||||
const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
|
||||
const MAX_OTP_REQUESTS_PER_HOUR: number = 3;
|
||||
const MAX_MESSAGES_PER_WEEK: number = 3;
|
||||
const OTP_STEP_IN_SEC: number = 300;
|
||||
const VALID_PAST_OTP_STEPS: number = 1;
|
||||
const VALID_FUTURE_OTP_STEPS: number = 1;
|
||||
const VALID_FUTURE_OTP_STEPS: number = 0;
|
||||
const OTP_NUM_DIGITS: number = 6;
|
||||
const MAX_GLOBAL_OTP_SENDS_PER_HOUR: number = 10;
|
||||
let globalOtpSendTimestamps: number[] = [];
|
||||
|
||||
export function isRateLimitedGlobally(): boolean {
|
||||
const now = Date.now();
|
||||
globalOtpSendTimestamps = globalOtpSendTimestamps.filter(
|
||||
(t) => now - t < ONE_HOUR_IN_MS,
|
||||
);
|
||||
return globalOtpSendTimestamps.length >= MAX_GLOBAL_OTP_SENDS_PER_HOUR;
|
||||
}
|
||||
|
||||
export function recordGlobalOtpSend() {
|
||||
globalOtpSendTimestamps.push(Date.now());
|
||||
}
|
||||
|
||||
function cleanupStaleEntries(map: Map<string, number[]>, windowMs: number) {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamps] of map) {
|
||||
const recent = timestamps.filter((t) => now - t < windowMs);
|
||||
if (recent.length === 0) map.delete(key);
|
||||
else if (recent.length !== timestamps.length) map.set(key, recent);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
cleanupStaleEntries(submissionTimestamps, ONE_WEEK_IN_MS);
|
||||
cleanupStaleEntries(otpRequestTimestamps, ONE_HOUR_IN_MS);
|
||||
}, ONE_HOUR_IN_MS).unref?.();
|
||||
|
||||
authenticator.options = {
|
||||
step: OTP_STEP_IN_SEC,
|
||||
|
|
@ -30,15 +58,14 @@ function getUserSecret(phoneNumber: string, salt: string): string {
|
|||
}
|
||||
|
||||
export function normalizePhone(phone: string) {
|
||||
const result = phone.replace(/[^\d]/g, "").trim().startsWith("1")
|
||||
? phone.substring(1)
|
||||
: phone;
|
||||
|
||||
if (result.length !== 10) {
|
||||
let digits = phone.replace(/\D/g, "");
|
||||
if (digits.length === 11 && digits.startsWith("1")) {
|
||||
digits = digits.slice(1);
|
||||
}
|
||||
if (digits.length !== 10) {
|
||||
throw new Error("Invalid phone number.");
|
||||
}
|
||||
|
||||
return result;
|
||||
return digits;
|
||||
}
|
||||
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
|
|
@ -46,9 +73,13 @@ export function isValidPhone(phone: string): boolean {
|
|||
const match = phone.match(/(\d{3})(\d{3})(\d{4})/);
|
||||
const [, prefix, exchange, station] = match ?? [];
|
||||
const isValidNANPFormat =
|
||||
/^[2-7][0-8][0-9]$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
|
||||
/^[2-9][0-9]{2}$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
|
||||
const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station);
|
||||
const isNot911Number = prefix !== "911" && exchange !== "911";
|
||||
const isNotTollFreeNumber = !(
|
||||
/^[8-9][0-9]{2}$/.test(prefix) &&
|
||||
/^(99|88|77|66|55|44|33|22|11|00)$/.test(prefix.slice(1, 3))
|
||||
);
|
||||
const isNot555Number = prefix !== "555" && exchange !== "555";
|
||||
const isNotPopSongNumber = exchange !== "867" && station !== "5309";
|
||||
|
||||
|
|
@ -57,6 +88,7 @@ export function isValidPhone(phone: string): boolean {
|
|||
isNotAllSameDigit &&
|
||||
isNot911Number &&
|
||||
isNot555Number &&
|
||||
isNotTollFreeNumber &&
|
||||
isNotPopSongNumber
|
||||
);
|
||||
}
|
||||
|
|
@ -153,4 +185,6 @@ export default {
|
|||
recordMsgSubmission,
|
||||
isRateLimitedForOtp,
|
||||
isRateLimitedForMsgs,
|
||||
isRateLimitedGlobally,
|
||||
recordGlobalOtpSend,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,30 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getActionContext } from "astro:actions";
|
||||
import type { APIContext, MiddlewareNext } from "astro";
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// htmz -> frame-ancestors 'self' & X-Frame-Options SAMEORIGIN
|
||||
// astro -> 'unsafe-inline'
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"worker-src 'self' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https://badblocks.goatcounter.com",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self' https://badblocks.goatcounter.com https://api.iconify.design https://cdn.jsdelivr.net",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; "),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
};
|
||||
|
||||
async function handle(context: APIContext, next: MiddlewareNext) {
|
||||
if (context.isPrerendered) return next();
|
||||
|
||||
const { action, setActionResult, serializeActionResult } =
|
||||
|
|
@ -10,10 +33,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
const currentAction = await context.session?.get("currentAction");
|
||||
|
||||
if (currentAction) {
|
||||
const { actionName, actionResult } = JSON.parse(currentAction);
|
||||
setActionResult(actionName, actionResult);
|
||||
|
||||
context.session?.delete("currentAction");
|
||||
try {
|
||||
const { actionName, actionResult } = JSON.parse(currentAction);
|
||||
setActionResult(actionName, actionResult);
|
||||
} catch {}
|
||||
return next();
|
||||
}
|
||||
|
||||
|
|
@ -39,13 +63,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
|
||||
context.session?.set("contactFormDraft", draft);
|
||||
|
||||
const referer = context.request.headers.get("Referer");
|
||||
if (!referer) {
|
||||
throw new Error(
|
||||
"Internal: Referer unexpectedly missing from Action POST request.",
|
||||
);
|
||||
}
|
||||
return context.redirect(referer);
|
||||
let redirectPath = context.originPathname;
|
||||
try {
|
||||
const referer = new URL(context.request.headers.get("Referer") ?? "");
|
||||
if (referer.origin === context.url.origin) {
|
||||
redirectPath = referer.pathname;
|
||||
}
|
||||
} catch {}
|
||||
return context.redirect(redirectPath);
|
||||
}
|
||||
|
||||
context.session?.delete("contactFormDraft");
|
||||
|
|
@ -53,4 +78,12 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const response = await handle(context, next);
|
||||
for (const [header, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
response.headers.set(header, value);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
|
|
|||
8
src/pages/ai.astro
Normal file
8
src/pages/ai.astro
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
import Layout from "@layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<title slot="head">AI Policy</title>
|
||||
<Fragment slot="main"> </Fragment>
|
||||
</Layout>
|
||||
|
|
@ -10,8 +10,21 @@ export const POST: APIRoute = async (context) => {
|
|||
);
|
||||
}
|
||||
|
||||
const { token, solutions } = await context.request.json();
|
||||
if (!token || !solutions) {
|
||||
let body: { token?: unknown; solutions?: unknown };
|
||||
try {
|
||||
body = await context.request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ success: false }), { status: 400 });
|
||||
}
|
||||
|
||||
const { token, solutions } = body ?? {};
|
||||
if (
|
||||
typeof token !== "string" ||
|
||||
token.length > 256 ||
|
||||
!Array.isArray(solutions) ||
|
||||
solutions.length > 128 ||
|
||||
!solutions.every((s) => typeof s === "number")
|
||||
) {
|
||||
return new Response(JSON.stringify({ success: false }), { status: 400 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const msgValue = pickValue("msg");
|
|||
errorIcon &&
|
||||
progressIcon
|
||||
) {
|
||||
cap.addEventListener("solve", function (e) {
|
||||
cap.addEventListener("solve", function () {
|
||||
const humanness = Math.round((85 + Math.random() * 14.9) * 10) / 10;
|
||||
statusText.textContent = `${humanness}% human. Good enough!`;
|
||||
progressIcon.classList.add("hidden");
|
||||
|
|
@ -60,7 +60,7 @@ const msgValue = pickValue("msg");
|
|||
initIcon.classList.add("hidden");
|
||||
completeIcon.classList.remove("hidden");
|
||||
});
|
||||
cap.addEventListener("error", function (e) {
|
||||
cap.addEventListener("error", function () {
|
||||
statusText.textContent = "Oops! We crashed!";
|
||||
progressIcon.classList.add("hidden");
|
||||
completeIcon.classList.add("hidden");
|
||||
|
|
@ -167,9 +167,12 @@ const msgValue = pickValue("msg");
|
|||
id="name"
|
||||
name="name"
|
||||
aria-describedby="name"
|
||||
placeholder="Alice Bob"
|
||||
placeholder="Alice Bobston"
|
||||
value={nameValue}
|
||||
/>
|
||||
{error.name && <p id="error_name">{error.name.join(",")}</p>}
|
||||
{"name" in error && error.name && (
|
||||
<p id="error_name">{error.name}</p>
|
||||
)}
|
||||
</label>
|
||||
<label for="phone">
|
||||
Phone
|
||||
|
|
@ -179,21 +182,19 @@ const msgValue = pickValue("msg");
|
|||
name="phone"
|
||||
aria-describedby="error_phone"
|
||||
placeholder="555-555-5555"
|
||||
value={phoneValue}
|
||||
/>
|
||||
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
|
||||
{"phone" in error && error.phone && (
|
||||
<p id="error_phone">{error.phone}</p>
|
||||
)}
|
||||
</label>
|
||||
<label for="msg">
|
||||
Msg
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="msg"
|
||||
name="msg"
|
||||
oninput="this.parentNode.dataset.replicatedValue = this.value"
|
||||
aria-describedby="error_msg"
|
||||
placeholder="I think badblocks rocks! Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||
/>
|
||||
<div class="textarea-wrapper" data-replicated-value={msgValue}>
|
||||
{/* prettier-ignore */}
|
||||
<textarea id="msg" name="msg" oninput="this.parentNode.dataset.replicatedValue = this.value" aria-describedby="error_msg" placeholder="I think badblocks rocks! Lorem ipsum dolor sit amet, consectetur adipiscing elit.">{msgValue}</textarea>
|
||||
</div>
|
||||
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
|
||||
{"msg" in error && error.msg && <p id="error_msg">{error.msg}</p>}
|
||||
</label>
|
||||
<button
|
||||
id="send_otp"
|
||||
|
|
@ -215,7 +216,7 @@ const msgValue = pickValue("msg");
|
|||
name="otp"
|
||||
aria-describedby="error_otp"
|
||||
/>
|
||||
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
|
||||
{"otp" in error && error.otp && <p id="error_otp">{error.otp}</p>}
|
||||
</label>
|
||||
<button
|
||||
id="reset"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Layout from "@layouts/BaseLayout.astro";
|
|||
<title slot="head">Home</title>
|
||||
<Fragment slot="main">
|
||||
<article id="hero">
|
||||
<h2>Under Construction</h2>
|
||||
<h2>It's badblocks!</h2>
|
||||
<p>Pardon the dust!</p>
|
||||
</article>
|
||||
</Fragment>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue