diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..9732267 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +Dockerfile +docker-compose.yml +npm-debug.log +.env.local diff --git a/.env.production b/.env.production new file mode 100755 index 0000000..0739b11 --- /dev/null +++ b/.env.production @@ -0,0 +1,9 @@ +SMTP_HOST=mail.mailnine24.de +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=contact@4l3ks.com +SMTP_PASS=TeoMoniNiki0/ +CONTACT_EMAIL=contact@4l3ks.com + +NEXT_PUBLIC_RECAPTCHA_SITE_KEY=6Lfx_VIsAAAAAKi_H46P2qpcvZAO9RHG-0p5NHOm +RECAPTCHA_SECRET_KEY=6Lfx_VIsAAAAAM97kz2dS9kKToyBbl87tqHKVTdQ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8f08e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +.env.local diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..4c36c12 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/.retool_types/**": true, + "**/*tsconfig.json": true, + ".cache": true, + "retool.config.json": true + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..de71887 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# -------- Build stage -------- +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# -------- Runtime stage -------- +FROM node:20-alpine + +WORKDIR /app + +# ✅ REQUIRED for SMTP + HTTPS +RUN apk add --no-cache ca-certificates + +ENV NODE_ENV=production + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100755 index 0000000..14759b0 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,104 @@ +import nodemailer from "nodemailer"; + +function confirmationTemplate(name: string) { + return ` +
+

Thanks for reaching out!

+

I will make my best to read your message as soon as possible!

+

This is just a confirmation — no need to reply to this email.

+
+

+ © ${new Date().getFullYear()} 4l3ks.com +

+
+ `; +} +async function verifyRecaptcha(token: string) { + const res = await fetch( + "https://www.google.com/recaptcha/api/siteverify", + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`, + } + ); + + return res.json(); +} + + +export async function POST(req: Request) { + try { + const { name, email, subject, message, token } = await req.json(); + + if (!process.env.RECAPTCHA_SECRET_KEY) { + throw new Error("Missing RECAPTCHA_SECRET_KEY"); + } + + + + if (!token) { + return Response.json( + { success: false, error: "Missing captcha token" }, + { status: 400 } + ); + } + + const captcha = await verifyRecaptcha(token); + + if (!captcha || captcha.success !== true) { + console.warn("Captcha failed:", captcha); + return Response.json( + { success: false, error: "Captcha failed" }, + { status: 403 } + ); + } + + // ✅ 2. ONLY NOW send emails + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST!, + port: Number(process.env.SMTP_PORT), + secure: false, + auth: { + user: process.env.SMTP_USER!, + pass: process.env.SMTP_PASS!, + }, + }); + + // Admin email + await transporter.sendMail({ + from: `"Contact Form" <${process.env.SMTP_USER!}>`, + to: process.env.CONTACT_EMAIL!, + replyTo: email, + subject: subject || `New message from ${email}`, + html: ` +

Name: ${name}

+

Email: ${email}

+

${message}

+ `, + }); + + // Confirmation email + await transporter.sendMail({ + from: `"4l3ks.com" <${process.env.SMTP_USER!}>`, + to: email, + subject: "Your message was received! :)", + html: confirmationTemplate(name), + }); + + return Response.json({ success: true }); +} catch (error) { + console.error("CONTACT API ERROR:", error); + + return Response.json( + { + success: false, + error: + error instanceof Error + ? error.message + : JSON.stringify(error), + }, + { status: 500 } + ); + } +} diff --git a/app/components/Certs.tsx b/app/components/Certs.tsx new file mode 100755 index 0000000..57a6b51 --- /dev/null +++ b/app/components/Certs.tsx @@ -0,0 +1,24 @@ +import Image from "next/image"; +import styles from "./components.module.css" + +export default function Projects() { + return ( +
+
+
+
+ + +
+
+
+
+
+
+
+ No projects yet... +
+
+
+ ); +} diff --git a/app/components/Clock.tsx b/app/components/Clock.tsx new file mode 100755 index 0000000..e15607f --- /dev/null +++ b/app/components/Clock.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function Clock() { + const [time, setTime] = useState(""); + + useEffect(() => { + const updateTime = () => { + const now = new Date(); + setTime( + now.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + ); + }; + + updateTime(); + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + }, []); + + return {time}; +} diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx new file mode 100755 index 0000000..21ff93b --- /dev/null +++ b/app/components/Contact.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect } from "react"; +import styles from "./components.module.css"; +import Script from "next/script"; + + +export default function Contact() { + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [pendingForm, setPendingForm] = + useState(null); + + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!captchaToken) { + setPendingForm(e.currentTarget); + // @ts-ignore + window.grecaptcha.execute(); + return; + } + + await sendForm(e.currentTarget); + } + + async function sendForm(form: HTMLFormElement) { + setLoading(true); + setStatus(null); + + const data = { + name: "Website Contact", + email: (form.elements.namedItem("from") as HTMLInputElement).value, + subject: (form.elements.namedItem("subject") as HTMLInputElement).value, + message: (form.elements.namedItem("message") as HTMLTextAreaElement).value, + token: captchaToken, + }; + + try { + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) throw new Error(); + + setStatus(true); + form.reset(); + } catch { + setStatus(false); + } finally { + setLoading(false); + setCaptchaToken(null); + // @ts-ignore + window.grecaptcha.reset(); + } + } + + useEffect(() => { + if (captchaToken && pendingForm) { + sendForm(pendingForm); + setPendingForm(null); + } + }, [captchaToken]); + + useEffect(() => { + if (status === true) { + const timer = setTimeout(() => { + setStatus(null); + }, 3_000); + + return () => clearTimeout(timer); + } + }, [status]); + + useEffect(() => { + (window as any).onRecaptchaSuccess = (token: string) => { + setCaptchaToken(token); + }; + }, []); + + + const icon = + status === null + ? "/img/icons/send.png" + : status === true + ? "/img/icons/check.png" + : "/img/icons/error.png"; + + + + + return ( +
+
+
+ +
+
+