rewrite number whatever to .net and blazor.

This commit is contained in:
Blake Ridgway
2026-02-08 21:05:52 -06:00
parent 3f7814d9c8
commit 6ae71b0216
51 changed files with 4531 additions and 1547 deletions

9
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/subscribers.db
/.venv/
/.github/
/__pycache__/
/.env
venv
bin/
obj/
*.user
*.suo
.vs/

View File

@@ -1,44 +1,28 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy csproj and restore dependencies
COPY landing.csproj .
RUN dotnet restore
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
# Copy everything and publish
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-o server ./cmd/landing
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
# Final stage
FROM alpine:latest
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/server .
# Copy templates directory
COPY --from=builder /app/templates ./templates
# Copy static files directory
COPY --from=builder /app/static ./static
# Copy .env (optional - can be overridden at runtime)
COPY .env .env
COPY --from=builder /app/publish .
EXPOSE 5000
CMD ["./server"]
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_RUNNING_IN_CONTAINER=true
CMD ["dotnet", "landing.dll"]

21
Data/AppDbContext.cs Normal file
View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using landing.Models;
namespace landing.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Subscriber> Subscribers => Set<Subscriber>();
public DbSet<Newsletter> Newsletters => Set<Newsletter>();
public DbSet<ContactMessage> ContactMessages => Set<ContactMessage>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Subscriber>(entity =>
{
entity.HasIndex(e => e.Email).IsUnique();
});
}
}

View File

@@ -0,0 +1,62 @@
using System.Diagnostics;
namespace landing.Middleware;
public class SecurityMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SecurityMiddleware> _logger;
private static readonly string[] BlockedPatterns =
{
"python-requests", "curl", "wget", "sqlmap", "nikto",
".php", ".env", ".git", "wp-admin", "xmlrpc", "backup", "config"
};
public SecurityMiddleware(RequestDelegate next, ILogger<SecurityMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var request = context.Request;
var userAgent = request.Headers.UserAgent.ToString().ToLowerInvariant();
var requestPath = request.Path.Value?.ToLowerInvariant() ?? "";
var queryString = request.QueryString.Value?.ToLowerInvariant() ?? "";
var fullUri = requestPath + queryString;
foreach (var pattern in BlockedPatterns)
{
if (fullUri.Contains(pattern))
{
_logger.LogWarning("BLOCKED attack: {Method} {Path} from {RemoteIp}",
request.Method, request.Path, context.Connection.RemoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Access Denied");
return;
}
if (userAgent.Contains(pattern))
{
_logger.LogWarning("BLOCKED bot: {UserAgent} from {RemoteIp}",
userAgent, context.Connection.RemoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Access Denied");
return;
}
}
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
_logger.LogInformation("{Method} {Path} {StatusCode} {Duration}ms {RemoteIp}",
request.Method,
request.Path,
context.Response.StatusCode,
sw.ElapsedMilliseconds,
context.Connection.RemoteIpAddress);
}
}

31
Models/ContactMessage.cs Normal file
View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace landing.Models;
[Table("contact_messages")]
public class ContactMessage
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("name")]
public string Name { get; set; } = string.Empty;
[Required]
[Column("email")]
public string Email { get; set; } = string.Empty;
[Required]
[Column("subject")]
public string Subject { get; set; } = string.Empty;
[Required]
[Column("message")]
public string Message { get; set; } = string.Empty;
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

23
Models/Newsletter.cs Normal file
View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace landing.Models;
[Table("newsletters")]
public class Newsletter
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("subject")]
public string Subject { get; set; } = string.Empty;
[Required]
[Column("body")]
public string Body { get; set; } = string.Empty;
[Column("sent_at")]
public DateTime SentAt { get; set; } = DateTime.UtcNow;
}

16
Models/Subscriber.cs Normal file
View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace landing.Models;
[Table("subscribers")]
public class Subscriber
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("email")]
public string Email { get; set; } = string.Empty;
}

310
Pages/About.cshtml Normal file
View File

@@ -0,0 +1,310 @@
@page
@model AboutModel
@{
ViewData["Title"] = "RideAware - About";
}
<section class="page-hero">
<h1>About RideAware</h1>
<p>Smart cycling training for every level</p>
</section>
<section class="about-mission">
<div class="container">
<div class="mission-text">
<h2>Our Mission</h2>
<p>
RideAware is dedicated to making cycling training accessible,
effective, and enjoyable for cyclists of all levels. We provide
intelligent training plans, real-time analytics, and community support
to help you achieve your cycling goals.
</p>
<p>
Every ride counts. We believe smart training combined with technology
can unlock your full potential as a cyclist.
</p>
<ul>
<li>AI-powered adaptive training plans</li>
<li>Real-time performance analytics</li>
<li>Expert coaching and guidance</li>
<li>Community-driven motivation</li>
<li>Seamless device integration</li>
</ul>
</div>
<div class="mission-visual">
<i class="fas fa-bicycle"></i>
</div>
</div>
</section>
<section class="values-section">
<div class="container">
<div class="section-header">
<div class="section-label"><i class="fas fa-compass"></i> Values</div>
<h2 class="section-title">What drives our mission</h2>
</div>
<div class="values-grid">
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-heart"></i>
</div>
<h3>Passion</h3>
<p>
We're cyclists ourselves. We understand the dedication it takes
to improve and achieve your goals.
</p>
</div>
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-brain"></i>
</div>
<h3>Intelligence</h3>
<p>
Our AI-driven platform learns from your performance to deliver
personalized training that actually works.
</p>
</div>
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-users"></i>
</div>
<h3>Community</h3>
<p>
Cycling is better together. Connect with other riders, share
achievements, and push each other forward.
</p>
</div>
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-chart-line"></i>
</div>
<h3>Transparency</h3>
<p>
See all your data clearly. We believe in giving you the insights
you need to understand your progress.
</p>
</div>
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-lightbulb"></i>
</div>
<h3>Innovation</h3>
<p>
Technology should enhance your cycling, not complicate it.
We're constantly improving to serve you better.
</p>
</div>
<div class="value-card fade-in">
<div class="value-icon">
<i class="fas fa-medal"></i>
</div>
<h3>Excellence</h3>
<p>
Whether you're training for a race or personal satisfaction,
we help you reach peak performance.
</p>
</div>
</div>
</div>
</section>
<section class="team-section">
<div class="container">
<div class="team-header">
<h2>Meet Our Team</h2>
<p>Cyclists and engineers building the future of training</p>
</div>
<div class="team-grid">
<div class="team-member fade-in">
<div class="team-member-image">
<i class="fas fa-user-circle"></i>
</div>
<div class="team-member-info">
<h3>Blake Ridgway</h3>
<p>Founder &amp; CEO</p>
<div class="bio">
Building the future of cycling training with scalable infrastructure
and performant systems. Passionate about Infrastructure-as-Code,
cloud networking, and creating observable platforms that ship faster
with confidence.
</div>
</div>
</div>
<div class="team-member fade-in">
<div class="team-member-image">
<i class="fas fa-user-circle"></i>
</div>
<div class="team-member-info">
<h3>Cycling Experts</h3>
<p>Training Advisors</p>
<div class="bio">
Professional cyclists and coaches ensuring our training plans
are effective and science-based.
</div>
</div>
</div>
<div class="team-member fade-in">
<div class="team-member-image">
<i class="fas fa-user-circle"></i>
</div>
<div class="team-member-info">
<h3>You</h3>
<p>Community</p>
<div class="bio">
Every rider using RideAware is part of our team. Your feedback
shapes our future.
</div>
</div>
</div>
</div>
</div>
</section>
<section class="stats-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">By The Numbers</h2>
<p class="section-subtitle" style="margin: 0 auto;">Growth and impact</p>
</div>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">Coming</div>
<div class="stat-label">Q4 2026</div>
</div>
<div class="stat-box">
<div class="stat-number">&infin;</div>
<div class="stat-label">Potential</div>
</div>
<div class="stat-box">
<div class="stat-number">100%</div>
<div class="stat-label">Passion</div>
</div>
<div class="stat-box">
<div class="stat-number">You</div>
<div class="stat-label">In Control</div>
</div>
</div>
</div>
</section>
<section class="faq-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Frequently Asked Questions</h2>
</div>
<div class="faq-container">
<div class="faq-item">
<div class="faq-question">
<h3>When is RideAware launching?</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="faq-answer">
<p>
We're launching Q4 2026! Sign up for our newsletter to get
early access and exclusive launch day bonuses.
</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h3>How much will it cost?</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="faq-answer">
<p>
Pricing details coming soon. We're committed to making RideAware
accessible to cyclists at all price points.
</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h3>What devices does RideAware support?</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="faq-answer">
<p>
RideAware works on iOS, Android, web, and integrates with all
major fitness trackers and cycling computers (Garmin, Wahoo, etc.).
</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h3>Is my data private?</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="faq-answer">
<p>
Yes. Your training data is yours alone. We'll never sell or share
your personal information with third parties.
</p>
</div>
</div>
<div class="faq-item">
<div class="faq-question">
<h3>Can I import my current training data?</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="faq-answer">
<p>
Yes! RideAware will integrate with Strava, TrainingPeaks, and other
platforms so you can bring all your history with you.
</p>
</div>
</div>
</div>
</div>
</section>
<section class="cta-section">
<div class="container">
<div class="cta-card">
<h2>Ready to Elevate Your Cycling?</h2>
<p>Join the waitlist and be first to know when we launch.</p>
<a href="/" class="action-btn primary">
Join the Waitlist
<i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
</section>
@section Scripts {
<script>
// FAQ accordion toggle
document.querySelectorAll('.faq-question').forEach((question) => {
question.addEventListener('click', () => {
const item = question.parentElement;
const isOpen = item.classList.contains('open');
document.querySelectorAll('.faq-item').forEach((faq) => {
faq.classList.remove('open');
});
if (!isOpen) {
item.classList.add('open');
}
});
});
</script>
}

10
Pages/About.cshtml.cs Normal file
View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace landing.Pages;
public class AboutModel : PageModel
{
public void OnGet()
{
}
}

224
Pages/Contact.cshtml Normal file
View File

@@ -0,0 +1,224 @@
@page
@model ContactModel
@{
ViewData["Title"] = "RideAware - Contact";
}
<section class="page-hero">
<h1>Get in Touch</h1>
<p>We'd love to hear from you. Send us a message!</p>
</section>
<section class="contact-layout">
<div class="container">
<div class="contact-info-side">
<h2>Let's Connect</h2>
<p>
Have a question about RideAware? Want to collaborate?
Reach out and let us know how we can help.
</p>
<ul>
<li>Fast response times</li>
<li>Friendly support team</li>
<li>Multiple contact options</li>
<li>Always here to help</li>
</ul>
<div class="contact-info">
<div class="info-card">
<div class="info-card-icon">
<i class="fas fa-envelope"></i>
</div>
<div>
<h3>Email</h3>
<p><a href="mailto:hello@rideaware.com">hello@rideaware.com</a></p>
</div>
</div>
<div class="info-card">
<div class="info-card-icon">
<i class="fas fa-map-marker-alt"></i>
</div>
<div>
<h3>Address</h3>
<p>1909 W Owen K Garriott Rd<br />Enid, OK 73703</p>
</div>
</div>
</div>
</div>
<form class="contact-form" id="contactForm">
<div class="form-success" id="successMessage">
<strong>Thank you!</strong> We've received your message
and will get back to you soon.
</div>
<h2>Send us a message</h2>
<div class="form-group">
<label for="name">
Full Name <span class="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
autocomplete="name"
/>
</div>
<div class="form-group">
<label for="email">
Email Address <span class="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
/>
<small>We'll respond to this email address</small>
</div>
<div class="form-group">
<label for="subject">Subject <span class="required">*</span></label>
<select id="subject" name="subject" required>
<option value="">-- Select a subject --</option>
<option value="general">General Inquiry</option>
<option value="support">Support</option>
<option value="partnership">Partnership</option>
<option value="feedback">Feedback</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="message">
Message <span class="required">*</span>
</label>
<textarea
id="message"
name="message"
required
placeholder="Your message here..."
></textarea>
</div>
<div class="newsletter-opt-in">
<label for="subscribe" class="checkbox-label">
<input
type="checkbox"
id="subscribe"
name="subscribe"
class="checkbox-input"
/>
<span class="checkbox-text">
<i class="fas fa-bell"></i>
Subscribe to our newsletter for training tips and updates
</span>
</label>
</div>
<!-- Simple Addition CAPTCHA -->
<div class="form-group">
<label for="captcha">
Quick Math Check <span class="required">*</span>
</label>
<div id="captchaQuestion" style="font-weight: bold; margin-bottom: 8px; color: var(--text-primary); font-size: 16px;"></div>
<input
type="text"
id="captcha"
name="captcha"
required
placeholder="Enter the answer"
autocomplete="off"
inputmode="numeric"
/>
</div>
<button type="submit" class="form-submit">
<i class="fas fa-paper-plane"></i>
Send Message
</button>
</form>
</div>
</section>
@section Scripts {
<script>
let currentCaptcha = null;
function generateCaptcha() {
const num1 = Math.floor(Math.random() * 20) + 1;
const num2 = Math.floor(Math.random() * 20) + 1;
const answer = num1 + num2;
return {
question: `${num1} + ${num2} = ?`,
answer: answer.toString()
};
}
function initCaptcha() {
currentCaptcha = generateCaptcha();
document.getElementById('captchaQuestion').textContent = currentCaptcha.question;
document.getElementById('captcha').value = '';
document.getElementById('captcha').focus();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCaptcha);
} else {
initCaptcha();
}
document.getElementById('contactForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userAnswer = document.getElementById('captcha').value.trim();
if (userAnswer !== currentCaptcha.answer) {
alert('Incorrect answer. Please try again.');
currentCaptcha = generateCaptcha();
document.getElementById('captchaQuestion').textContent = currentCaptcha.question;
document.getElementById('captcha').value = '';
document.getElementById('captcha').focus();
return;
}
const form = e.target;
const formData = new FormData(form);
formData.delete('captcha');
try {
const response = await fetch('/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(formData),
});
const data = await response.json();
if (response.ok) {
document.getElementById('successMessage').classList.add('show');
form.reset();
initCaptcha();
setTimeout(() => {
document.getElementById('successMessage').classList.remove('show');
}, 5000);
} else {
alert('Error: ' + (data.error || 'Failed to send message'));
initCaptcha();
}
} catch (error) {
console.error('Error:', error);
alert('Failed to send message: ' + error.message);
initCaptcha();
}
});
</script>
}

158
Pages/Contact.cshtml.cs Normal file
View File

@@ -0,0 +1,158 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using landing.Data;
using landing.Models;
using landing.Services;
namespace landing.Pages;
[IgnoreAntiforgeryToken]
public class ContactModel : PageModel
{
private readonly AppDbContext _db;
private readonly EmailService _email;
private readonly SpamDetectionService _spam;
private readonly IConfiguration _config;
private readonly ILogger<ContactModel> _logger;
public ContactModel(
AppDbContext db,
EmailService email,
SpamDetectionService spam,
IConfiguration config,
ILogger<ContactModel> logger)
{
_db = db;
_email = email;
_spam = spam;
_config = config;
_logger = logger;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
var name = Request.Form["name"].ToString().Trim();
var email = Request.Form["email"].ToString().Trim();
var subject = Request.Form["subject"].ToString().Trim();
var message = Request.Form["message"].ToString().Trim();
var subscribe = Request.Form["subscribe"].ToString() == "on";
// Validate required fields
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(email) ||
string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(message))
{
return JsonError("All fields are required", 400);
}
if (!_spam.IsValidName(name))
{
_logger.LogWarning("Rejected submission: Invalid name format - {Name}", name);
return JsonError("Please provide a valid name", 400);
}
if (!_spam.IsValidEmail(email))
{
_logger.LogWarning("Rejected submission: Invalid email format - {Email}", email);
return JsonError("Please provide a valid email address", 400);
}
if (!_spam.IsValidSubject(subject))
{
_logger.LogWarning("Rejected submission: Invalid subject - {Subject}", subject);
return JsonError("Please select a valid subject", 400);
}
if (message.Length < 10)
return JsonError("Message must be at least 10 characters", 400);
if (message.Length > 5000)
return JsonError("Message must be less than 5000 characters", 400);
if (!_spam.IsEnglishText(message))
{
_logger.LogWarning("Rejected submission: Non-English message from {Name} ({Email})", name, email);
return JsonError("Please submit your message in English", 400);
}
if (_spam.IsSpamMessage(message))
{
_logger.LogWarning("Rejected spam submission from {Name} ({Email})", name, email);
return JsonError("Your message was flagged as spam. Please try again with a different message.", 400);
}
// If subscribe checkbox is checked, add to subscribers
if (subscribe)
{
try
{
_db.Subscribers.Add(new Subscriber { Email = email });
await _db.SaveChangesAsync();
_logger.LogInformation("New subscriber added: {Email}", email);
}
catch
{
_logger.LogInformation("Subscriber {Email} already exists or failed to add", email);
}
}
// Send confirmation email to the user
try
{
await _email.SendContactConfirmationAsync(email, name);
_logger.LogInformation("Contact confirmation email sent to {Email}", email);
}
catch (Exception ex)
{
_logger.LogError("Failed to send contact confirmation to {Email}: {Error}", email, ex.Message);
}
// Send notification email to admin
var adminEmail = _config["ADMIN_EMAIL"];
if (!string.IsNullOrEmpty(adminEmail))
{
try
{
await _email.SendContactNotificationAsync(adminEmail, name, email, subject, message);
_logger.LogInformation("Contact notification sent to admin: {AdminEmail}", adminEmail);
}
catch (Exception ex)
{
_logger.LogError("Failed to send contact notification to admin: {Error}", ex.Message);
}
}
// Save contact message to database
try
{
_db.ContactMessages.Add(new ContactMessage
{
Name = name,
Email = email,
Subject = subject,
Message = message,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning("Failed to save contact message: {Error}", ex.Message);
}
_logger.LogInformation("Contact form submitted by {Name} ({Email})", name, email);
Response.StatusCode = 201;
return new JsonResult(new { message = "Thank you for your message. We'll get back to you soon!" });
}
private IActionResult JsonError(string error, int statusCode)
{
Response.StatusCode = statusCode;
return new JsonResult(new { error });
}
}

26
Pages/Error.cshtml Normal file
View File

@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

27
Pages/Error.cshtml.cs Normal file
View File

@@ -0,0 +1,27 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace landing.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}

262
Pages/Index.cshtml Normal file
View File

@@ -0,0 +1,262 @@
@page
@model IndexModel
@{
ViewData["Title"] = "RideAware - Smart Cycling Training Platform";
}
<!-- Hero -->
<section class="hero">
<div class="hero-content">
<div class="hero-badge">
<span class="pulse-dot"></span>
Development Build Available
</div>
<h1>Elevate Your<br /><span class="gradient-text">Cycling Journey</span></h1>
<p class="subtitle">
The smart training platform for cyclists who demand excellence.
AI-powered plans, real-time analytics, and community-driven motivation.
</p>
<div class="hero-cta">
<input
type="email"
class="email-input"
id="email-input"
placeholder="Enter your email"
required
/>
<button class="btn-primary" id="notify-button">Subscribe</button>
</div>
<p class="hero-note">Join the newsletter for feature updates and early access.</p>
<a href="https://dev.rideaware.org" class="action-btn secondary" target="_blank" style="margin-top: 1rem;">
<i class="fas fa-rocket"></i>
Access Development Build
</a>
</div>
</section>
<!-- Social Proof -->
<section class="proof-bar">
<div class="container">
<div class="proof-item">
<div class="number">Q4 2026</div>
<div class="label">Launch Target</div>
</div>
<div class="proof-item">
<div class="number">6+</div>
<div class="label">Core Features</div>
</div>
<div class="proof-item">
<div class="number">AI</div>
<div class="label">Powered Training</div>
</div>
<div class="proof-item">
<div class="number">100%</div>
<div class="label">Data Privacy</div>
</div>
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="container">
<div class="section-header">
<div class="section-label"><i class="fas fa-sparkles"></i> Features</div>
<h2 class="section-title">Everything you need to train smarter</h2>
<p class="section-subtitle">
Comprehensive tools to optimize your cycling performance, from planning to recovery.
</p>
</div>
<div class="features-grid">
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-calendar-alt"></i>
</div>
<h3>Smart Training Plans</h3>
<p>AI-powered plans that adapt to your goals, fitness level, and schedule automatically.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-chart-line"></i>
</div>
<h3>Advanced Analytics</h3>
<p>Interactive charts, progress insights, and detailed performance metrics over time.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-bicycle"></i>
</div>
<h3>Virtual Training</h3>
<p>Expert coaching and immersive structured workouts designed for peak performance.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-heart"></i>
</div>
<h3>Health &amp; Recovery</h3>
<p>Nutrition tracking, recovery optimization, and proactive injury prevention tools.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-users"></i>
</div>
<h3>Community &amp; Social</h3>
<p>Connect with fellow cyclists, share achievements, and climb the leaderboards.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">
<i class="fas fa-sync-alt"></i>
</div>
<h3>Smart Integration</h3>
<p>Sync with Garmin, Wahoo, and other wearables. Import from Strava and TrainingPeaks.</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="how-it-works">
<div class="container">
<div class="section-header">
<div class="section-label"><i class="fas fa-route"></i> How It Works</div>
<h2 class="section-title">Three steps to better training</h2>
<p class="section-subtitle">
Get started in minutes and let RideAware handle the complexity.
</p>
</div>
<div class="steps-grid">
<div class="step-card fade-in">
<div class="step-number-ring">
<span class="gradient-text" style="font-weight: 700; font-size: 1.1rem;">1</span>
</div>
<h3>Sign Up</h3>
<p>Create your profile and set your cycling goals and fitness level.</p>
</div>
<div class="step-card fade-in">
<div class="step-number-ring">
<span class="gradient-text" style="font-weight: 700; font-size: 1.1rem;">2</span>
</div>
<h3>Set Goals</h3>
<p>Define what you want to achieve and let our AI build your plan.</p>
</div>
<div class="step-card fade-in">
<div class="step-number-ring">
<span class="gradient-text" style="font-weight: 700; font-size: 1.1rem;">3</span>
</div>
<h3>Train Smarter</h3>
<p>Follow your adaptive plan, track progress, and crush your goals.</p>
</div>
</div>
</div>
</section>
<!-- Roadmap -->
<section class="roadmap">
<div class="container">
<div class="section-header">
<div class="section-label"><i class="fas fa-code-branch"></i> Roadmap</div>
<h2 class="section-title">Development Status</h2>
<p class="section-subtitle">
RideAware is actively being built. Track our progress and help shape the platform.
</p>
</div>
<div class="timeline">
<div class="timeline-item live">
<div class="timeline-dot"></div>
<div class="timeline-badge">Live</div>
<h4>User Authentication</h4>
<p>Sign up, login, and user profiles fully functional</p>
</div>
<div class="timeline-item live">
<div class="timeline-dot"></div>
<div class="timeline-badge">Live</div>
<h4>Equipment Management</h4>
<p>Add and manage your bikes and cycling gear</p>
</div>
<div class="timeline-item live">
<div class="timeline-dot"></div>
<div class="timeline-badge">Live</div>
<h4>Training Zones</h4>
<p>HR and power zone calculations</p>
</div>
<div class="timeline-item in-progress">
<div class="timeline-dot"></div>
<div class="timeline-badge">In Progress</div>
<h4>Workout Planning</h4>
<p>Structured workout builder with interval support</p>
</div>
<div class="timeline-item in-progress">
<div class="timeline-dot"></div>
<div class="timeline-badge">In Progress</div>
<h4>Performance Analytics</h4>
<p>Detailed performance tracking and metrics</p>
</div>
<div class="timeline-item planned">
<div class="timeline-dot"></div>
<div class="timeline-badge">Planned</div>
<h4>Device Integration</h4>
<p>Sync with wearables and cycling computers</p>
</div>
<div class="timeline-item planned">
<div class="timeline-dot"></div>
<div class="timeline-badge">Planned</div>
<h4>Community Features</h4>
<p>Social sharing and competitive leaderboards</p>
</div>
<div class="timeline-item planned">
<div class="timeline-dot"></div>
<div class="timeline-badge">Planned</div>
<h4>Advanced Training</h4>
<p>AI-powered coaching and personalized plans</p>
</div>
</div>
<div style="text-align: center; margin-top: 3rem;">
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Try what's live today</p>
<a href="https://dev.rideaware.org" class="action-btn primary" target="_blank">
<i class="fas fa-rocket"></i>
Access Development Build
</a>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta-section">
<div class="container">
<div class="cta-card">
<h2>Ready to elevate your cycling?</h2>
<p>Join the waitlist and be first to know when we launch.</p>
<div class="hero-cta">
<input
type="email"
class="email-input"
id="cta-email-input"
placeholder="Enter your email"
/>
<button class="btn-primary" id="cta-notify-button">Subscribe</button>
</div>
</div>
</div>
</section>

10
Pages/Index.cshtml.cs Normal file
View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace landing.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
}

View File

@@ -0,0 +1,101 @@
@page "/newsletter/{id:int}"
@model NewsletterDetailModel
@{
ViewData["Title"] = $"RideAware - {Model.NewsletterItem?.Subject}";
}
@if (Model.NewsletterItem != null)
{
<div class="article-wrap">
<aside class="article-aside">
<a href="/newsletters" class="back-link">
<i class="fas fa-arrow-left"></i>
Back to Newsletters
</a>
<div class="article-meta">
<h2 class="article-title">
@Model.NewsletterItem.Subject
</h2>
<div class="meta-row">
<i class="fas fa-calendar-alt"></i>
<span>@Model.NewsletterItem.SentAt.ToString("MMMM d, yyyy 'at' h:mm tt")</span>
</div>
</div>
<nav class="toc">
<div class="toc-title">On this page</div>
<ol id="toc-list"></ol>
</nav>
</aside>
<main class="article-main">
<header class="article-hero">
<div class="newsletter-icon">
<i class="fas fa-envelope-open-text"></i>
</div>
<h1>@Model.NewsletterItem.Subject</h1>
</header>
<article class="newsletter-content" id="article">
@Html.Raw(Model.NewsletterItem.Body)
</article>
<div class="newsletter-actions">
<a href="/newsletters" class="action-btn primary">
<i class="fas fa-list"></i>
View All Newsletters
</a>
<button onclick="window.print()" class="action-btn secondary">
<i class="fas fa-print"></i>
Print
</button>
<button onclick="shareNewsletter()" class="action-btn secondary">
<i class="fas fa-share-alt"></i>
Share
</button>
</div>
</main>
</div>
}
@section Scripts {
<script>
function shareNewsletter() {
if (navigator.share) {
navigator
.share({ title: document.title, url: location.href })
.catch(() => {});
} else {
navigator.clipboard.writeText(location.href);
alert('Link copied to clipboard!');
}
}
// Build TOC from h2/h3 inside the article
(function buildTOC() {
const article = document.getElementById('article');
if (!article) return;
const headings = article.querySelectorAll('h2, h3');
const list = document.getElementById('toc-list');
if (!headings.length || !list) return;
headings.forEach((h, idx) => {
const id = h.id || `h-${idx}`;
h.id = id;
const li = document.createElement('li');
li.className = h.tagName === 'H2' ? 'toc-h2' : 'toc-h3';
const a = document.createElement('a');
a.href = `#${id}`;
a.textContent = h.textContent;
li.appendChild(a);
list.appendChild(li);
});
})();
</script>
}

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using landing.Data;
using landing.Models;
namespace landing.Pages;
public class NewsletterDetailModel : PageModel
{
private readonly AppDbContext _db;
public NewsletterDetailModel(AppDbContext db)
{
_db = db;
}
public Newsletter? NewsletterItem { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
NewsletterItem = await _db.Newsletters.FindAsync(id);
if (NewsletterItem == null)
return NotFound("Newsletter not found");
return Page();
}
}

78
Pages/Newsletters.cshtml Normal file
View File

@@ -0,0 +1,78 @@
@page
@model NewslettersModel
@{
ViewData["Title"] = "RideAware - Newsletters";
}
<section class="page-header">
<div class="page-header-content">
<div class="header-icon">
<i class="fas fa-newspaper"></i>
</div>
<h1>RideAware Newsletters</h1>
<p>
Stay updated with the latest cycling tips, training insights, and
product updates from our team.
</p>
</div>
</section>
<main class="main-content">
@if (Model.NewslettersList.Any())
{
<div class="newsletters-grid">
@foreach (var newsletter in Model.NewslettersList)
{
<article class="newsletter-card">
<div class="newsletter-header">
<div class="newsletter-icon">
<i class="fas fa-envelope-open-text"></i>
</div>
<div class="newsletter-info">
<h2>
<a href="/newsletter/@newsletter.Id">
@newsletter.Subject
</a>
</h2>
</div>
</div>
<div class="newsletter-date">
<i class="fas fa-calendar-alt"></i>
<span>Sent on: @newsletter.SentAt.ToString("yyyy-MM-dd HH:mm:ss")</span>
</div>
<div class="newsletter-excerpt">
Get the latest updates on cycling training, performance tips,
and RideAware features in this newsletter edition.
</div>
<a
href="/newsletter/@newsletter.Id"
class="read-more-btn"
>
Read Full Newsletter
<i class="fas fa-arrow-right"></i>
</a>
</article>
}
</div>
}
else
{
<div class="empty-state">
<div class="empty-icon">
<i class="fas fa-inbox"></i>
</div>
<h3>No Newsletters Yet</h3>
<p>
We're working on some amazing content for you. Subscribe to be the
first to know when we publish our newsletters!
</p>
<a href="/" class="subscribe-prompt">
<i class="fas fa-bell"></i>
Subscribe for Updates
</a>
</div>
}
</main>

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using landing.Data;
using landing.Models;
namespace landing.Pages;
public class NewslettersModel : PageModel
{
private readonly AppDbContext _db;
public NewslettersModel(AppDbContext db)
{
_db = db;
}
public List<Newsletter> NewslettersList { get; set; } = new();
public async Task OnGetAsync()
{
NewslettersList = await _db.Newsletters
.OrderByDescending(n => n.SentAt)
.ToListAsync();
}
}

125
Pages/Shared/_Layout.cshtml Normal file
View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>@(ViewData["Title"] ?? "RideAware")</title>
<!-- Icons/Fonts -->
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<!-- Core CSS -->
<link rel="stylesheet" href="~/css/styles.css" />
<!-- Favicons -->
<link
rel="icon"
type="image/png"
sizes="32x32"
href="~/assets/32x32.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="~/assets/apple-touch-icon.png"
/>
<link rel="manifest" href="~/assets/site.webmanifest" />
<meta name="theme-color" content="#09090b" />
@await RenderSectionAsync("Head", required: false)
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="logo" aria-label="RideAware home">
<img
src="~/assets/logo.png"
alt="RideAware"
class="logo-img"
width="120"
height="24"
decoding="async"
fetchpriority="high"
/>
</a>
<ul class="nav-links" id="primary-nav">
<li><a href="/#features">Features</a></li>
<li><a href="/newsletters">Newsletters</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<button
class="nav-toggle"
id="nav-toggle"
aria-label="Toggle navigation menu"
aria-controls="primary-nav"
aria-expanded="false"
>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</button>
</div>
</nav>
@RenderBody()
<footer class="footer">
<p>&copy; 2026 RideAware. All rights reserved.</p>
</footer>
<!-- Core JS -->
<script defer src="~/js/main.min.js" crossorigin="anonymous"></script>
@if (!IsSectionDefined("Scripts"))
{
<script>
(function() {
const btn = document.getElementById('nav-toggle');
const menu = document.getElementById('primary-nav');
if (!btn || !menu) return;
function closeMenu() {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
menu.classList.remove('open');
}
btn.addEventListener('click', () => {
const isOpen = btn.classList.toggle('active');
btn.setAttribute('aria-expanded', String(isOpen));
menu.classList.toggle('open', isOpen);
});
menu.addEventListener('click', (e) => {
if (e.target.tagName === 'A') closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !btn.contains(e.target)) {
closeMenu();
}
});
})();
</script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

2
Pages/Subscribe.cshtml Normal file
View File

@@ -0,0 +1,2 @@
@page
@model SubscribeModel

76
Pages/Subscribe.cshtml.cs Normal file
View File

@@ -0,0 +1,76 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using landing.Data;
using landing.Models;
using landing.Services;
namespace landing.Pages;
[IgnoreAntiforgeryToken]
public class SubscribeModel : PageModel
{
private readonly AppDbContext _db;
private readonly EmailService _email;
private readonly ILogger<SubscribeModel> _logger;
public SubscribeModel(AppDbContext db, EmailService email, ILogger<SubscribeModel> logger)
{
_db = db;
_email = email;
_logger = logger;
}
public IActionResult OnGet() => NotFound();
public async Task<IActionResult> OnPostAsync()
{
string? emailAddress = null;
// Try to read JSON body
try
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var json = JsonSerializer.Deserialize<JsonElement>(body);
emailAddress = json.GetProperty("email").GetString();
}
catch
{
return BadRequest(new { error = "Invalid request" });
}
if (string.IsNullOrWhiteSpace(emailAddress))
{
return BadRequest(new { error = "Email is required" });
}
try
{
_db.Subscribers.Add(new Subscriber { Email = emailAddress });
await _db.SaveChangesAsync();
}
catch
{
return BadRequest(new { error = "Email already exists" });
}
// Build unsubscribe link
var scheme = Request.Scheme;
var host = Request.Host.Value;
var unsubscribeLink = $"{scheme}://{host}/unsubscribe?email={emailAddress}";
try
{
await _email.SendConfirmationEmailAsync(emailAddress, unsubscribeLink);
_logger.LogInformation("Confirmation email sent to {Email}", emailAddress);
}
catch (Exception ex)
{
_logger.LogError("Failed to send confirmation email to {Email}: {Error}", emailAddress, ex.Message);
}
Response.StatusCode = 201;
return new JsonResult(new { message = "Email has been added" });
}
}

7
Pages/Unsubscribe.cshtml Normal file
View File

@@ -0,0 +1,7 @@
@page
@model UnsubscribeModel
@{
ViewData["Title"] = "Unsubscribe";
Layout = null;
}
@Model.ResultMessage

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using landing.Data;
namespace landing.Pages;
public class UnsubscribeModel : PageModel
{
private readonly AppDbContext _db;
private readonly ILogger<UnsubscribeModel> _logger;
public UnsubscribeModel(AppDbContext db, ILogger<UnsubscribeModel> logger)
{
_db = db;
_logger = logger;
}
public string ResultMessage { get; set; } = string.Empty;
public async Task<IActionResult> OnGetAsync([FromQuery] string? email)
{
if (string.IsNullOrWhiteSpace(email))
{
Response.StatusCode = 400;
ResultMessage = "No email specified";
return Page();
}
var subscriber = await _db.Subscribers.FirstOrDefaultAsync(s => s.Email == email);
if (subscriber == null)
{
Response.StatusCode = 400;
ResultMessage = $"Email {email} was not found or already unsubscribed";
return Page();
}
_db.Subscribers.Remove(subscriber);
await _db.SaveChangesAsync();
_logger.LogInformation("Unsubscribed {Email}", email);
ResultMessage = $"The email {email} has been unsubscribed.";
return Page();
}
}

View File

@@ -0,0 +1,4 @@
@using landing
@using landing.Models
@namespace landing.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

3
Pages/_ViewStart.cshtml Normal file
View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

74
Program.cs Normal file
View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using landing.Data;
using landing.Middleware;
using landing.Services;
var builder = WebApplication.CreateBuilder(args);
// Load .env file if present (for local development)
var envPath = Path.Combine(builder.Environment.ContentRootPath, ".env");
if (File.Exists(envPath))
{
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
continue;
var idx = trimmed.IndexOf('=');
if (idx <= 0) continue;
var key = trimmed[..idx].Trim();
var value = trimmed[(idx + 1)..].Trim();
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(key)))
Environment.SetEnvironmentVariable(key, value);
}
}
// Add environment variables to configuration
builder.Configuration.AddEnvironmentVariables();
// Build connection string from env vars
var pgHost = builder.Configuration["PG_HOST"] ?? "localhost";
var pgPort = builder.Configuration["PG_PORT"] ?? "5432";
var pgDatabase = builder.Configuration["PG_DATABASE"] ?? "rideaware";
var pgUser = builder.Configuration["PG_USER"] ?? "postgres";
var pgPassword = builder.Configuration["PG_PASSWORD"] ?? "";
var connectionString = $"Host={pgHost};Port={pgPort};Database={pgDatabase};Username={pgUser};Password={pgPassword}";
// Register services
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddSingleton<EmailService>();
builder.Services.AddSingleton<SpamDetectionService>();
builder.Services.AddRazorPages();
var app = builder.Build();
// Ensure database tables exist
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
}
// Middleware pipeline
app.UseMiddleware<SecurityMiddleware>();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
// Configure Kestrel to listen on the right port
var host = builder.Configuration["HOST"] ?? "0.0.0.0";
var port = builder.Configuration["PORT"] ?? "5000";
app.Run($"http://{host}:{port}");

View File

@@ -0,0 +1,29 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:18915",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5182",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

98
Services/EmailService.cs Normal file
View File

@@ -0,0 +1,98 @@
using System.Net.Security;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace landing.Services;
public class EmailService
{
private readonly IConfiguration _config;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration config, ILogger<EmailService> logger)
{
_config = config;
_logger = logger;
}
public async Task SendConfirmationEmailAsync(string email, string unsubscribeLink)
{
var subject = "Thanks for subscribing!";
var htmlBody = $@"
<html>
<body>
<h1>Welcome to RideAware!</h1>
<p>Thank you for subscribing to our newsletter.</p>
<p><a href=""{System.Net.WebUtility.HtmlEncode(unsubscribeLink)}"">Unsubscribe</a></p>
</body>
</html>";
await SendEmailAsync(email, subject, htmlBody);
}
public async Task SendContactConfirmationAsync(string email, string name)
{
var subject = "We received your message - RideAware";
var escapedName = System.Net.WebUtility.HtmlEncode(name);
var htmlBody = $@"
<html>
<body>
<h2>Thank you for reaching out, {escapedName}!</h2>
<p>We've received your message and will get back to you as soon as possible.</p>
<p>In the meantime, feel free to check out more about RideAware on our website.</p>
<p>Best regards,<br>The RideAware Team</p>
</body>
</html>";
await SendEmailAsync(email, subject, htmlBody);
}
public async Task SendContactNotificationAsync(string adminEmail, string name, string email, string contactSubject, string message)
{
var emailSubject = $"New contact message from {name}";
var escapedName = System.Net.WebUtility.HtmlEncode(name);
var escapedEmail = System.Net.WebUtility.HtmlEncode(email);
var escapedSubject = System.Net.WebUtility.HtmlEncode(contactSubject);
var escapedMessage = System.Net.WebUtility.HtmlEncode(message).Replace("\n", "<br>");
var htmlBody = $@"
<html>
<body>
<h3>New Contact Message</h3>
<p><strong>From:</strong> {escapedName} ({escapedEmail})</p>
<p><strong>Subject:</strong> {escapedSubject}</p>
<h4>Message:</h4>
<p>{escapedMessage}</p>
</body>
</html>";
await SendEmailAsync(adminEmail, emailSubject, htmlBody);
}
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody)
{
var smtpHost = _config["SMTP_SERVER"] ?? throw new InvalidOperationException("SMTP_SERVER not configured");
var smtpPort = int.Parse(_config["SMTP_PORT"] ?? "465");
var smtpUser = _config["SMTP_USER"] ?? throw new InvalidOperationException("SMTP_USER not configured");
var smtpPass = _config["SMTP_PASSWORD"] ?? throw new InvalidOperationException("SMTP_PASSWORD not configured");
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(smtpUser));
message.To.Add(MailboxAddress.Parse(toEmail));
message.Subject = subject;
message.Body = new TextPart("html") { Text = htmlBody };
using var client = new SmtpClient();
// Accept the server's certificate (matching Go's TLS behavior)
client.ServerCertificateValidationCallback = (sender, certificate, chain, errors) =>
errors == SslPolicyErrors.None || errors == SslPolicyErrors.RemoteCertificateChainErrors;
// Port 465 uses direct SSL/TLS (SslOnConnect)
await client.ConnectAsync(smtpHost, smtpPort, SecureSocketOptions.SslOnConnect);
await client.AuthenticateAsync(smtpUser, smtpPass);
await client.SendAsync(message);
await client.DisconnectAsync(true);
}
}

View File

@@ -0,0 +1,221 @@
using System.Text.RegularExpressions;
namespace landing.Services;
public class SpamDetectionService
{
private static readonly string[] SpamPatterns =
{
"viagra", "cialis", "casino", "lottery", "prize",
"click here", "buy now", "limited time",
"congratulations", "you have won", "claim your",
"bitcoin", "crypto", "forex", "trading bot",
"free money", "make money fast", "work from home",
"nigerian", "inheritance", "transfer funds",
"<!--", "javascript:", "onclick=", "<script",
"sveiki", "ciao", "hola", "\u043f\u0440\u0438\u0432\u0435\u0442",
"harga", "karna", "anda", "dari",
"toughalia", "comfythings",
"robertgok"
};
private static readonly string[] EnglishWords =
{
"the", "and", "is", "to", "of", "for", "that", "with", "this", "have",
"from", "be", "are", "was", "were", "been", "i", "you", "he", "she",
"we", "they", "my", "your", "his", "her", "it", "what", "which", "who",
"when", "where", "why", "how", "can", "will", "would", "should", "could",
"do", "does", "did", "get", "got", "go", "going", "make", "made", "know",
"think", "want", "need", "like", "help", "work", "use", "ask", "say", "tell",
"give", "find", "tell", "become", "leave", "feel", "try", "ask", "need",
"meet", "include", "continue", "set", "learn", "change", "lead", "understand"
};
private static readonly HashSet<string> CommonPairs = new()
{
"th", "he", "in", "er", "an",
"ed", "nd", "to", "en", "ti",
"es", "or", "te", "ar", "ou",
"it", "ha", "is", "co", "me",
"we", "be", "se", "as", "de",
"so", "re", "st", "up", "at",
"ai", "al", "il", "le", "li"
};
private static readonly HashSet<string> ValidSubjects = new()
{
"general", "support", "partnership", "feedback", "other"
};
private static readonly Regex UrlRegex = new(@"https?://", RegexOptions.Compiled);
private static readonly Regex EmailRegex = new(@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", RegexOptions.Compiled);
private static readonly Regex PhoneRegex = new(@"\+?[0-9]{7,}", RegexOptions.Compiled);
public bool IsValidName(string name)
{
if (name.Length < 2 || name.Length > 100)
return false;
int numberCount = name.Count(char.IsDigit);
if (numberCount > 0 && (double)numberCount / name.Length > 0.33)
return false;
if (name.Contains("http") || name.Contains("://"))
return false;
return true;
}
public bool IsValidEmail(string email)
{
if (!email.Contains('@') || !email.Contains('.'))
return false;
var parts = email.Split('@');
if (parts.Length != 2)
return false;
if (parts[0].Length < 1 || parts[0].Length > 64)
return false;
if (parts[1].Length < 3 || parts[1].Length > 255)
return false;
var domainParts = parts[1].Split('.');
if (domainParts.Length < 2)
return false;
foreach (var label in domainParts)
{
if (label.Length < 1 || label.Length > 63)
return false;
}
return true;
}
public bool IsValidSubject(string subject) => ValidSubjects.Contains(subject);
public bool IsEnglishText(string text)
{
if (string.IsNullOrEmpty(text))
return true;
var lowerText = text.ToLowerInvariant();
var words = lowerText.Split(
new[] { ' ', '\t', '\n', '\r', '.', ',', '!', '?', ';', ':', '-', '(', ')', '[', ']', '{', '}', '"', '\'' },
StringSplitOptions.RemoveEmptyEntries
);
int englishWordCount = 0;
int totalWords = 0;
foreach (var word in words)
{
if (word.Length == 0) continue;
totalWords++;
if (EnglishWords.Contains(word))
englishWordCount++;
}
if (text.Length < 50)
return englishWordCount >= 1;
if (text.Length < 200)
return englishWordCount >= 2;
if (totalWords > 0)
return (double)englishWordCount / totalWords >= 0.1;
return true;
}
public bool IsSpamMessage(string message)
{
var lowerMsg = message.ToLowerInvariant();
// Check spam keywords
foreach (var pattern in SpamPatterns)
{
if (lowerMsg.Contains(pattern))
return true;
}
// Check for excessive URLs
if (UrlRegex.Matches(lowerMsg).Count > 1)
return true;
// Check for email addresses in message
if (EmailRegex.IsMatch(lowerMsg))
return true;
// Check for phone numbers
if (PhoneRegex.IsMatch(lowerMsg))
return true;
// Check for excessive exclamation marks
if (lowerMsg.Count(c => c == '!') > 2)
return true;
// Check for repeated characters
if (lowerMsg.Contains("!!!") || lowerMsg.Contains("???") || lowerMsg.Contains("..."))
return true;
// Check for all caps
if (lowerMsg.Length > 20)
{
int letterCount = 0;
int capsCount = 0;
foreach (var c in message)
{
if (char.IsLetter(c))
{
letterCount++;
if (char.IsUpper(c))
capsCount++;
}
}
if (letterCount > 0 && (double)capsCount / letterCount > 0.6)
return true;
}
// Check for repeated words
var words = lowerMsg.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length > 5)
{
var wordCount = new Dictionary<string, int>();
foreach (var word in words)
{
wordCount.TryGetValue(word, out int count);
wordCount[word] = count + 1;
}
if (wordCount.Values.Any(c => c > 3))
return true;
}
// Check message length - very short messages are often spam
if (message.Length < 15)
return true;
// Check for gibberish - high ratio of uncommon character transitions
int uncommonCount = 0;
for (int i = 0; i < lowerMsg.Length - 1; i++)
{
char c = lowerMsg[i];
char next = lowerMsg[i + 1];
if (c >= 'a' && c <= 'z' && next >= 'a' && next <= 'z')
{
var pair = new string(new[] { c, next });
if (!CommonPairs.Contains(pair) && c != next)
uncommonCount++;
}
}
if (lowerMsg.Length > 30 && uncommonCount > lowerMsg.Length / 3)
return true;
return false;
}
}

View File

@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

22
appsettings.json Normal file
View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"PG_HOST": "localhost",
"PG_PORT": "5432",
"PG_DATABASE": "rideaware",
"PG_USER": "postgres",
"PG_PASSWORD": "",
"SMTP_SERVER": "",
"SMTP_PORT": "465",
"SMTP_USER": "",
"SMTP_PASSWORD": "",
"ADMIN_EMAIL": "",
"JWT_SECRET_KEY": "",
"HOST": "0.0.0.0",
"PORT": "5000"
}

View File

@@ -1,51 +0,0 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"landing/internal/config"
"landing/internal/database"
"landing/internal/handlers"
)
func main() {
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Initialize database
db, err := database.New(cfg)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close(context.Background())
// Initialize database schema
if err := db.InitDB(context.Background()); err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
// Create handler with dependencies
h := handlers.New(db, cfg)
// Start HTTP server
go func() {
log.Printf("starting server on %s:%s", cfg.Host, cfg.Port)
if err := h.Start(cfg.Host, cfg.Port); err != nil {
log.Printf("server error: %v", err)
}
}()
// Graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
log.Println("shutting down server")
}

18
go.mod
View File

@@ -1,18 +0,0 @@
module landing
go 1.25.4
require (
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
)
require (
github.com/altcha-org/altcha-lib-go v0.2.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

32
go.sum
View File

@@ -1,32 +0,0 @@
github.com/altcha-org/altcha-lib-go v0.2.2 h1:KY7a7jFUf6tFKZF6MzuZMhSWuGMv0MtVkK/Kj4Oas38=
github.com/altcha-org/altcha-lib-go v0.2.2/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,55 +0,0 @@
package config
import (
"fmt"
"os"
"github.com/joho/godotenv"
)
type Config struct {
Host string
Port string
DBHost string
DBPort string
DBName string
DBUser string
DBPass string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
AdminEmail string
}
func LoadConfig() (*Config, error) {
godotenv.Load()
cfg := &Config{
Host: getEnv("HOST", "0.0.0.0"),
Port: getEnv("PORT", "5000"),
DBHost: getEnv("PG_HOST", ""),
DBPort: getEnv("PG_PORT", ""),
DBName: getEnv("PG_DATABASE", ""),
DBUser: getEnv("PG_USER", ""),
DBPass: getEnv("PG_PASSWORD", ""),
SMTPHost: getEnv("SMTP_SERVER", ""),
SMTPPort: getEnv("SMTP_PORT", ""),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPass: getEnv("SMTP_PASSWORD", ""),
AdminEmail: os.Getenv("ADMIN_EMAIL"),
}
if cfg.SMTPHost == "" {
return nil, fmt.Errorf("SMTP_SERVER not configured")
}
return cfg, nil
}
func getEnv(key, defaultVal string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultVal
}

View File

@@ -1,190 +0,0 @@
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"landing/internal/config"
"landing/internal/models"
)
type DB struct {
pool *pgxpool.Pool
}
func New(cfg *config.Config) (*DB, error) {
// Use proper pgx connection config instead of URL parsing
connConfig, err := pgxpool.ParseConfig("")
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
connConfig.ConnConfig.Host = cfg.DBHost
connConfig.ConnConfig.Port = 5432
connConfig.ConnConfig.Database = cfg.DBName
connConfig.ConnConfig.User = cfg.DBUser
connConfig.ConnConfig.Password = cfg.DBPass
ctx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, connConfig)
if err != nil {
return nil, fmt.Errorf("failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{pool: pool}, nil
}
func (db *DB) InitDB(ctx context.Context) error {
queries := []string{
`CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS newsletters (
id SERIAL PRIMARY KEY,
subject TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS contact_messages (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, query := range queries {
if _, err := db.pool.Exec(ctx, query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
}
return nil
}
func (db *DB) AddSubscriber(
ctx context.Context,
email string,
) error {
_, err := db.pool.Exec(
ctx,
"INSERT INTO subscribers (email) VALUES ($1)",
email,
)
if err != nil {
if err.Error() == "ERROR: duplicate key value" {
return fmt.Errorf("email already exists")
}
return err
}
return nil
}
func (db *DB) RemoveSubscriber(
ctx context.Context,
email string,
) error {
result, err := db.pool.Exec(
ctx,
"DELETE FROM subscribers WHERE email = $1",
email,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return fmt.Errorf("email not found")
}
return nil
}
func (db *DB) GetNewsletters(
ctx context.Context,
) ([]models.Newsletter, error) {
rows, err := db.pool.Query(
ctx,
"SELECT id, subject, body, sent_at FROM newsletters "+
"ORDER BY sent_at DESC",
)
if err != nil {
return nil, err
}
defer rows.Close()
var newsletters []models.Newsletter
for rows.Next() {
var n models.Newsletter
err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
if err != nil {
return nil, err
}
newsletters = append(newsletters, n)
}
return newsletters, rows.Err()
}
func (db *DB) GetNewsletter(
ctx context.Context,
id int,
) (*models.Newsletter, error) {
var n models.Newsletter
err := db.pool.QueryRow(
ctx,
"SELECT id, subject, body, sent_at FROM newsletters "+
"WHERE id = $1",
id,
).Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("newsletter not found")
}
if err != nil {
return nil, err
}
return &n, nil
}
func (db *DB) AddContactMessage(
ctx context.Context,
name, email, subject, message string,
) error {
query := `
INSERT INTO contact_messages (name, email, subject, message, created_at)
VALUES ($1, $2, $3, $4, $5)
`
_, err := db.pool.Exec(
ctx,
query,
name,
email,
subject,
message,
time.Now(),
)
return err
}
func (db *DB) Close(ctx context.Context) {
db.pool.Close()
}

View File

@@ -1,181 +0,0 @@
package email
import (
"crypto/tls"
"fmt"
"html"
"net/smtp"
"strconv"
"strings"
"landing/internal/config"
)
type Sender struct {
cfg *config.Config
}
func New(cfg *config.Config) *Sender {
return &Sender{cfg: cfg}
}
func (s *Sender) SendConfirmationEmail(
email string,
unsubscribeLink string,
) error {
subject := "Thanks for subscribing!"
htmlBody := fmt.Sprintf(`
<html>
<body>
<h1>Welcome to RideAware!</h1>
<p>Thank you for subscribing to our newsletter.</p>
<p><a href="%s">Unsubscribe</a></p>
</body>
</html>
`, unsubscribeLink)
return s.sendEmail(email, subject, htmlBody)
}
func (s *Sender) SendContactConfirmation(email, name string) error {
subject := "We received your message - RideAware"
htmlBody := fmt.Sprintf(`
<html>
<body>
<h2>Thank you for reaching out, %s!</h2>
<p>We've received your message and will get back to you as soon as possible.</p>
<p>In the meantime, feel free to check out more about RideAware on our website.</p>
<p>Best regards,<br>The RideAware Team</p>
</body>
</html>
`, html.EscapeString(name))
return s.sendEmail(email, subject, htmlBody)
}
func (s *Sender) SendContactNotification(
adminEmail, name, email, subject, message string,
) error {
emailSubject := fmt.Sprintf("New contact message from %s", name)
htmlBody := fmt.Sprintf(`
<html>
<body>
<h3>New Contact Message</h3>
<p><strong>From:</strong> %s (%s)</p>
<p><strong>Subject:</strong> %s</p>
<h4>Message:</h4>
<p>%s</p>
</body>
</html>
`,
html.EscapeString(name),
html.EscapeString(email),
html.EscapeString(subject),
strings.ReplaceAll(html.EscapeString(message), "\n", "<br>"),
)
return s.sendEmail(adminEmail, emailSubject, htmlBody)
}
func (s *Sender) sendEmail(toEmail, subject, htmlBody string) error {
port, err := strconv.Atoi(s.cfg.SMTPPort)
if err != nil {
return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
}
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
s.cfg.SMTPUser,
toEmail,
subject,
htmlBody,
)
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
// Port 465 uses direct SSL/TLS
return s.sendEmailSSL(addr, toEmail, message)
}
func (s *Sender) sendEmailSSL(addr, toEmail, message string) error {
// Create TLS config
tlsConfig := &tls.Config{
ServerName: s.cfg.SMTPHost,
}
// Try to dial with TLS
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to dial TLS to %s: %w", addr, err)
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Authenticate
auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate with %s: %w", s.cfg.SMTPUser, err)
}
// Set sender
if err := client.Mail(s.cfg.SMTPUser); err != nil {
return fmt.Errorf("failed to set mail from %s: %w", s.cfg.SMTPUser, err)
}
// Set recipient
if err := client.Rcpt(toEmail); err != nil {
return fmt.Errorf("failed to set mail to %s: %w", toEmail, err)
}
// Get data writer
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// Write message
if _, err := wc.Write([]byte(message)); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
// Quit - ignore quit errors since email was already queued
_ = client.Quit()
return nil
}
func (s *Sender) TestConnection() error {
port, err := strconv.Atoi(s.cfg.SMTPPort)
if err != nil {
return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
}
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
// Test TLS connection
tlsConfig := &tls.Config{
ServerName: s.cfg.SMTPHost,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to dial TLS to %s: %w", addr, err)
}
defer conn.Close()
// Test SMTP client creation
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
return nil
}

View File

@@ -1,817 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"text/template"
"time"
"unicode"
"landing/internal/config"
"landing/internal/database"
"landing/internal/email"
)
type Handler struct {
db *database.DB
cfg *config.Config
email *email.Sender
templatesPath string
logger *log.Logger
}
func New(db *database.DB, cfg *config.Config) *Handler {
templatesPath := "templates"
if _, err := os.Stat(templatesPath); os.IsNotExist(err) {
templatesPath = "./templates"
}
return &Handler{
db: db,
cfg: cfg,
email: email.New(cfg),
templatesPath: templatesPath,
logger: log.New(os.Stdout, "", log.LstdFlags),
}
}
// loggingMiddleware logs HTTP requests
func (h *Handler) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userAgent := r.UserAgent()
// Block malicious bots and common attack patterns
blockedPatterns := []string{
"python-requests",
"curl",
"wget",
"sqlmap",
"nikto",
".php",
".env",
".git",
"wp-admin",
"xmlrpc",
"backup",
"config",
}
for _, pattern := range blockedPatterns {
if strings.Contains(strings.ToLower(r.RequestURI), strings.ToLower(pattern)) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "Access Denied")
h.logger.Printf("BLOCKED attack: %s %s from %s", r.Method, r.RequestURI, r.RemoteAddr)
return
}
if strings.Contains(strings.ToLower(userAgent), strings.ToLower(pattern)) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "Access Denied")
h.logger.Printf("BLOCKED bot: %s from %s", userAgent, r.RemoteAddr)
return
}
}
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
statusColor := getStatusColor(wrapped.statusCode)
methodColor := getMethodColor(r.Method)
h.logger.Printf(
"%s %s %s %s %s %d %s",
methodColor+r.Method+"\033[0m",
r.RequestURI,
statusColor+fmt.Sprintf("%d", wrapped.statusCode)+"\033[0m",
duration.String(),
r.RemoteAddr,
wrapped.contentLength,
userAgent,
)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
contentLength int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
rw.contentLength = len(b)
return rw.ResponseWriter.Write(b)
}
// Color codes for terminal output
func getStatusColor(statusCode int) string {
switch {
case statusCode >= 200 && statusCode < 300:
return "\033[32m" // Green
case statusCode >= 300 && statusCode < 400:
return "\033[36m" // Cyan
case statusCode >= 400 && statusCode < 500:
return "\033[33m" // Yellow
case statusCode >= 500:
return "\033[31m" // Red
default:
return "\033[37m" // White
}
}
func getMethodColor(method string) string {
switch method {
case "GET":
return "\033[34m" // Blue
case "POST":
return "\033[32m" // Green
case "PUT":
return "\033[33m" // Yellow
case "DELETE":
return "\033[31m" // Red
default:
return "\033[37m" // White
}
}
func (h *Handler) Start(host, port string) error {
mux := http.NewServeMux()
// Serve static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
mux.HandleFunc("/", h.indexHandler)
mux.HandleFunc("/subscribe", h.subscribeHandler)
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
mux.HandleFunc("/newsletters", h.newslettersHandler)
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
mux.HandleFunc("/contact", h.contactHandler)
mux.HandleFunc("/about", h.aboutHandler)
// Wrap with logging middleware
handler := h.loggingMiddleware(mux)
h.logger.Printf("\033[36m▶ Starting server on http://%s:%s\033[0m", host, port)
return http.ListenAndServe(host+":"+port, handler)
}
func (h *Handler) getTemplatePath(name string) string {
return filepath.Join(h.templatesPath, name)
}
func (h *Handler) indexHandler(
w http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tmpl, err := template.ParseFiles(
h.getTemplatePath("base.html"),
h.getTemplatePath("index.html"),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"IsHome": true,
}
tmpl.ExecuteTemplate(w, "base.html", data)
}
func (h *Handler) subscribeHandler(
w http.ResponseWriter,
r *http.Request,
) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.Email == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Email is required",
})
return
}
if err := h.db.AddSubscriber(r.Context(), req.Email); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Email already exists",
})
return
}
unsubscribeLink := fmt.Sprintf(
"%s/unsubscribe?email=%s",
getBaseURL(r),
req.Email,
)
if err := h.email.SendConfirmationEmail(
req.Email,
unsubscribeLink,
); err != nil {
h.logger.Printf("❌ Failed to send confirmation email to %s: %v", req.Email, err)
} else {
h.logger.Printf("✓ Confirmation email sent to %s", req.Email)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "Email has been added",
})
}
func (h *Handler) unsubscribeHandler(
w http.ResponseWriter,
r *http.Request,
) {
email := r.URL.Query().Get("email")
if email == "" {
http.Error(w, "No email specified", http.StatusBadRequest)
return
}
if err := h.db.RemoveSubscriber(r.Context(), email); err != nil {
http.Error(
w,
fmt.Sprintf(
"Email %s was not found or already unsubscribed",
email,
),
http.StatusBadRequest,
)
return
}
h.logger.Printf("✓ Unsubscribed %s", email)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "The email %s has been unsubscribed.", email)
}
func (h *Handler) newslettersHandler(
w http.ResponseWriter,
r *http.Request,
) {
newsletters, err := h.db.GetNewsletters(r.Context())
if err != nil {
http.Error(w, "Failed to fetch newsletters", 500)
return
}
tmpl, err := template.ParseFiles(
h.getTemplatePath("base.html"),
h.getTemplatePath("newsletters.html"),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
return
}
tmpl.ExecuteTemplate(w, "base.html", newsletters)
}
func (h *Handler) newsletterDetailHandler(
w http.ResponseWriter,
r *http.Request,
) {
idStr := r.URL.Path[len("/newsletter/"):]
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid newsletter ID", http.StatusBadRequest)
return
}
newsletter, err := h.db.GetNewsletter(r.Context(), id)
if err != nil {
http.Error(w, "Newsletter not found", http.StatusNotFound)
return
}
tmpl, err := template.ParseFiles(
h.getTemplatePath("base.html"),
h.getTemplatePath("newsletter_detail.html"),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
return
}
tmpl.ExecuteTemplate(w, "base.html", newsletter)
}
func (h *Handler) contactHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodGet {
tmpl, err := template.ParseFiles(
h.getTemplatePath("base.html"),
h.getTemplatePath("contact.html"),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"IsContact": true,
}
tmpl.ExecuteTemplate(w, "base.html", data)
return
}
if r.Method == http.MethodPost {
h.handleContactSubmission(w, r)
}
}
// isEnglishText checks if text is primarily in English
func isEnglishText(text string) bool {
if len(text) == 0 {
return true
}
lowerText := strings.ToLower(text)
// Very common English words that should appear in legitimate English messages
requiredEnglishWords := []string{
"the", "and", "is", "to", "of", "for", "that", "with", "this", "have",
"from", "be", "are", "was", "were", "been", "i", "you", "he", "she",
"we", "they", "my", "your", "his", "her", "it", "what", "which", "who",
"when", "where", "why", "how", "can", "will", "would", "should", "could",
"do", "does", "did", "get", "got", "go", "going", "make", "made", "know",
"think", "want", "need", "like", "help", "work", "use", "ask", "say", "tell",
"give", "find", "tell", "become", "leave", "feel", "try", "ask", "need",
"meet", "include", "continue", "set", "learn", "change", "lead", "understand",
}
englishWordCount := 0
totalWords := 0
// Split into words
words := strings.FieldsFunc(lowerText, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
for _, word := range words {
if len(word) > 0 {
totalWords++
// Check if word is in our English word list
for _, engWord := range requiredEnglishWords {
if word == engWord {
englishWordCount++
break
}
}
}
}
// For short messages (less than 50 characters), be more lenient
if len(text) < 50 {
return englishWordCount >= 1
}
// For medium messages (50-200 chars), require at least 2 English words
if len(text) < 200 {
return englishWordCount >= 2
}
// For longer messages, require at least 10% of words to be common English words
if totalWords > 0 {
englishPercentage := float64(englishWordCount) / float64(totalWords)
return englishPercentage >= 0.1
}
return true
}
// isSpamMessage checks if a message looks like spam
func isSpamMessage(message string) bool {
// Convert to lowercase for checks
lowerMsg := strings.ToLower(message)
// Check for common spam patterns
spamPatterns := []string{
"viagra", "cialis", "casino", "lottery", "prize",
"click here", "buy now", "limited time",
"congratulations", "you have won", "claim your",
"bitcoin", "crypto", "forex", "trading bot",
"free money", "make money fast", "work from home",
"nigerian", "inheritance", "transfer funds",
"<!--", "javascript:", "onclick=", "<script",
"sveiki", "ciao", "hola", "привет",
"harga", "karna", "anda", "dari",
"toughalia", "comfythings",
"robertgok",
}
for _, pattern := range spamPatterns {
if strings.Contains(lowerMsg, pattern) {
return true
}
}
// Check for excessive URLs
urlRegex := regexp.MustCompile(`https?://`)
if len(urlRegex.FindAllString(lowerMsg, -1)) > 1 {
return true
}
// Check for email addresses in message (spam often includes contact info)
emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
emailMatches := emailRegex.FindAllString(lowerMsg, -1)
if len(emailMatches) > 0 {
return true
}
// Check for phone numbers (often spam)
phoneRegex := regexp.MustCompile(`\+?[0-9]{7,}`)
if len(phoneRegex.FindAllString(lowerMsg, -1)) > 0 {
return true
}
// Check for excessive special characters
exclamationCount := strings.Count(lowerMsg, "!")
if exclamationCount > 2 {
return true
}
// Check for repeated characters
if strings.Contains(lowerMsg, "!!!") || strings.Contains(lowerMsg, "???") ||
strings.Contains(lowerMsg, "...") {
return true
}
// Check for all caps
if len(lowerMsg) > 20 {
letterCount := 0
capsCount := 0
for _, r := range message {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
letterCount++
if r >= 'A' && r <= 'Z' {
capsCount++
}
}
}
if letterCount > 0 && float64(capsCount)/float64(letterCount) > 0.6 {
return true
}
}
// Check for repeated words
words := strings.Fields(lowerMsg)
if len(words) > 5 {
wordCount := make(map[string]int)
for _, word := range words {
wordCount[word]++
}
for _, count := range wordCount {
if count > 3 {
return true
}
}
}
// Check message length - very short messages are often spam
if len(message) < 15 {
return true
}
// Check for gibberish - high ratio of uncommon character transitions
uncommonCount := 0
for i := 0; i < len(lowerMsg)-1; i++ {
char := lowerMsg[i]
nextChar := lowerMsg[i+1]
// Check for unlikely letter combinations
if (char >= 'a' && char <= 'z') && (nextChar >= 'a' && nextChar <= 'z') {
// Common pairs in English
commonPairs := map[string]bool{
"th": true, "he": true, "in": true, "er": true, "an": true,
"ed": true, "nd": true, "to": true, "en": true, "ti": true,
"es": true, "or": true, "te": true, "ar": true, "ou": true,
"it": true, "ha": true, "is": true, "co": true, "me": true,
"we": true, "be": true, "se": true, "as": true, "de": true,
"so": true, "re": true, "st": true, "up": true, "at": true,
"ai": true, "al": true, "il": true, "le": true, "li": true,
}
pair := string([]byte{char, nextChar})
if !commonPairs[pair] && char != nextChar {
uncommonCount++
}
}
}
if len(lowerMsg) > 30 && uncommonCount > len(lowerMsg)/3 {
return true
}
return false
}
// isValidName checks if name looks legitimate
func isValidName(name string) bool {
// Name should be at least 2 characters and at most 100
if len(name) < 2 || len(name) > 100 {
return false
}
// Name should not contain excessive numbers
numberCount := 0
for _, r := range name {
if r >= '0' && r <= '9' {
numberCount++
}
}
if numberCount > 0 && float64(numberCount)/float64(len(name)) > 0.33 {
return false
}
// Name should not contain URLs
if strings.Contains(name, "http") || strings.Contains(name, "://") {
return false
}
return true
}
// isValidEmail checks if email looks legitimate
func isValidEmail(email string) bool {
// Basic email validation
if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
return false
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return false
}
// Local part should be 1-64 chars
if len(parts[0]) < 1 || len(parts[0]) > 64 {
return false
}
// Domain part should be 3-255 chars
if len(parts[1]) < 3 || len(parts[1]) > 255 {
return false
}
// Check for valid domain structure
domainParts := strings.Split(parts[1], ".")
if len(domainParts) < 2 {
return false
}
// Each domain label should be 1-63 chars
for _, label := range domainParts {
if len(label) < 1 || len(label) > 63 {
return false
}
}
return true
}
func (h *Handler) handleContactSubmission(w http.ResponseWriter, r *http.Request) {
// Parse form data
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to parse form data",
})
return
}
// Extract form fields
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
subject := strings.TrimSpace(r.FormValue("subject"))
message := strings.TrimSpace(r.FormValue("message"))
subscribe := r.FormValue("subscribe") == "on"
// Validate required fields
if name == "" || email == "" || subject == "" || message == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "All fields are required",
})
return
}
// Validate name format
if !isValidName(name) {
h.logger.Printf("⚠ Rejected submission: Invalid name format - %s", name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Please provide a valid name",
})
return
}
// Validate email format
if !isValidEmail(email) {
h.logger.Printf("⚠ Rejected submission: Invalid email format - %s", email)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Please provide a valid email address",
})
return
}
// Validate subject
validSubjects := map[string]bool{
"general": true,
"support": true,
"partnership": true,
"feedback": true,
"other": true,
}
if !validSubjects[subject] {
h.logger.Printf("⚠ Rejected submission: Invalid subject - %s", subject)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Please select a valid subject",
})
return
}
// Validate message length
if len(message) < 10 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Message must be at least 10 characters",
})
return
}
if len(message) > 5000 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Message must be less than 5000 characters",
})
return
}
// Check if message is in English
if !isEnglishText(message) {
h.logger.Printf("⚠ Rejected submission: Non-English message from %s (%s)", name, email)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Please submit your message in English",
})
return
}
// Check if message is spam
if isSpamMessage(message) {
h.logger.Printf("⚠ Rejected spam submission from %s (%s): %s", name, email, message[:100])
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Your message was flagged as spam. Please try again with a different message.",
})
return
}
// If subscribe checkbox is checked, add to subscribers
if subscribe {
if err := h.db.AddSubscriber(r.Context(), email); err != nil {
h.logger.Printf(
" Subscriber %s already exists or failed to add: %v",
email,
err,
)
} else {
h.logger.Printf("✓ New subscriber added: %s", email)
}
}
// Send confirmation email to the user
if err := h.email.SendContactConfirmation(email, name); err != nil {
h.logger.Printf(
"❌ Failed to send contact confirmation to %s: %v",
email,
err,
)
} else {
h.logger.Printf("✓ Contact confirmation email sent to %s", email)
}
// Send notification email to admin
adminEmail := h.cfg.AdminEmail
if adminEmail != "" {
if err := h.email.SendContactNotification(
adminEmail,
name,
email,
subject,
message,
); err != nil {
h.logger.Printf(
"❌ Failed to send contact notification to admin: %v",
err,
)
} else {
h.logger.Printf("✓ Contact notification sent to admin: %s", adminEmail)
}
}
// Save contact message to database
if err := h.db.AddContactMessage(r.Context(), name, email, subject, message); err != nil {
h.logger.Printf(
"⚠ Failed to save contact message: %v",
err,
)
}
h.logger.Printf("✓ Contact form submitted by %s (%s)", name, email)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "Thank you for your message. We'll get back to you soon!",
})
}
func (h *Handler) aboutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tmpl, err := template.ParseFiles(
h.getTemplatePath("base.html"),
h.getTemplatePath("about.html"),
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
h.logger.Printf("❌ Template parse error: %v", err)
return
}
data := map[string]interface{}{
"IsAbout": true,
}
w.Header().Set("Content-Type", "text/html")
tmpl.ExecuteTemplate(w, "base.html", data)
}
func getBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, r.Host)
}

View File

@@ -1,15 +0,0 @@
package models
import "time"
type Subscriber struct {
ID int
Email string
}
type Newsletter struct {
ID int
Subject string
Body string
SentAt time.Time
}

14
landing.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.9.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
</ItemGroup>
</Project>

View File

@@ -1,174 +1,68 @@
#!/bin/bash
set -e
# Colors for output
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
CYAN='\033[0;36m'
NC='\033[0m'
# Default values
IMAGE_NAME="rideaware-landing"
IMAGE_TAG="latest"
NO_CACHE=false
RUN_CONTAINER=false
CONTAINER_NAME="rideaware-landing"
CONTAINER_NAME=""
RUN_AFTER=false
NO_CACHE=""
# Help function
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-t, --tag TAG Image tag (default: latest)
-n, --name NAME Image name (default: rideaware-landing)
-r, --run Run container after build
-c, --container NAME Container name when running (default: rideaware-landing)
--no-cache Build without cache
-h, --help Show this help message
EXAMPLES:
$0 # Build as rideaware:latest
$0 -t v1.0 # Build as rideaware:v1.0
$0 -t dev --run # Build and run
$0 --no-cache -t prod # Build without cache as rideaware:prod
EOF
exit 0
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -t, --tag TAG Image tag (default: latest)"
echo " -n, --name NAME Image name (default: rideaware-landing)"
echo " -r, --run Run container after build"
echo " -c, --container NAME Container name for running"
echo " --no-cache Build without cache"
echo " -h, --help Show this help"
echo ""
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
IMAGE_TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
-r|--run)
RUN_CONTAINER=true
shift
;;
-c|--container)
CONTAINER_NAME="$2"
shift 2
;;
--no-cache)
NO_CACHE=true
shift
;;
-h|--help)
show_help
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
show_help
;;
-t|--tag) IMAGE_TAG="$2"; shift 2 ;;
-n|--name) IMAGE_NAME="$2"; shift 2 ;;
-r|--run) RUN_AFTER=true; shift ;;
-c|--container) CONTAINER_NAME="$2"; shift 2 ;;
--no-cache) NO_CACHE="--no-cache"; shift ;;
-h|--help) show_help; exit 0 ;;
*) echo "Unknown option: $1"; show_help; exit 1 ;;
esac
done
FULL_IMAGE="$IMAGE_NAME:$IMAGE_TAG"
BUILD_ARGS=""
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
if [ "$NO_CACHE" = true ]; then
BUILD_ARGS="--no-cache"
fi
# Function to stop and remove container
cleanup_container() {
local name=$1
if podman ps -a --format "{{.Names}}" | grep -q "^${name}\$"; then
echo -e "${YELLOW}Removing existing container: $name${NC}"
# Stop if running
if podman ps --format "{{.Names}}" | grep -q "^${name}\$"; then
echo " Stopping container..."
podman kill "$name" 2>/dev/null || true
fi
# Remove
echo " Removing container..."
if podman rm "$name" 2>/dev/null; then
echo -e "${GREEN} ✓ Container removed${NC}"
else
echo -e "${RED} ✗ Failed to remove container${NC}"
return 1
fi
fi
return 0
echo -e "${CYAN}Building ${FULL_IMAGE}...${NC}"
podman build ${NO_CACHE} -t "${FULL_IMAGE}" -f Containerfile . || {
echo -e "${RED}Build failed${NC}"
exit 1
}
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Building Podman Image${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo -e "${YELLOW}Image: $FULL_IMAGE${NC}"
echo ""
echo -e "${GREEN}Build successful: ${FULL_IMAGE}${NC}"
podman images "${IMAGE_NAME}"
if ! podman build $BUILD_ARGS -f Containerfile -t "$FULL_IMAGE" .; then
echo -e "${RED}✗ Build failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Image built successfully${NC}"
echo ""
# Show image info
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Image Details ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
podman images "$IMAGE_NAME:$IMAGE_TAG" \
--format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Created}}"
echo ""
if [ "$RUN_CONTAINER" = true ]; then
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Starting Container ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
# Cleanup existing container
if ! cleanup_container "$CONTAINER_NAME"; then
echo -e "${RED}✗ Failed to clean up existing container${NC}"
exit 1
if [ "$RUN_AFTER" = true ]; then
NAME_FLAG=""
if [ -n "$CONTAINER_NAME" ]; then
# Stop and remove existing container
podman kill "$CONTAINER_NAME" 2>/dev/null
podman rm "$CONTAINER_NAME" 2>/dev/null
NAME_FLAG="--name ${CONTAINER_NAME}"
fi
echo ""
echo "Starting new container: $CONTAINER_NAME"
echo -e "${CYAN}Starting container...${NC}"
podman run -d ${NAME_FLAG} -p 5000:5000 --env-file .env "${FULL_IMAGE}"
if podman run -d \
--name "$CONTAINER_NAME" \
-p 5000:5000 \
--env-file .env \
"$FULL_IMAGE"; then
echo -e "${GREEN}✓ Container running: $CONTAINER_NAME${NC}"
echo ""
# Wait for startup
sleep 2
echo -e "${YELLOW}Container logs:${NC}"
echo -e "${GREEN}Container started on http://localhost:5000${NC}"
if [ -n "$CONTAINER_NAME" ]; then
podman logs "$CONTAINER_NAME"
echo ""
echo -e "${GREEN}Site available at: http://localhost:5000${NC}"
echo -e "${YELLOW}To view logs: podman logs -f $CONTAINER_NAME${NC}"
echo -e "${YELLOW}To stop: podman kill $CONTAINER_NAME${NC}"
else
echo -e "${RED}✗ Failed to start container${NC}"
exit 1
fi
else
echo -e "${YELLOW}To run the container:${NC}"
echo " podman run -d --name $CONTAINER_NAME -p 5000:5000 --env-file .env $FULL_IMAGE"
echo ""
echo -e "${YELLOW}Or use this script with --run:${NC}"
echo " $0 -t $IMAGE_TAG --run"
fi
echo ""
echo -e "${GREEN}✓ Done!${NC}"

BIN
wwwroot/assets/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
wwwroot/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

1924
wwwroot/css/styles.css Normal file

File diff suppressed because it is too large Load Diff

25
wwwroot/js/countdown.js Normal file
View File

@@ -0,0 +1,25 @@
// Countdown timer
const targetDate = new Date("2025-12-31T00:00:00Z");
function updateCountdown() {
const now = new Date();
const difference = targetDate - now;
if (difference < 0) {
document.getElementById("countdown").innerHTML = "<p style='color: white; font-size: 1.5rem;'>We're Live!</p>";
return;
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / (1000 * 60)) % 60);
const seconds = Math.floor((difference / 1000) % 60);
document.getElementById("days").textContent = days.toString().padStart(2, "0");
document.getElementById("hours").textContent = hours.toString().padStart(2, "0");
document.getElementById("minutes").textContent = minutes.toString().padStart(2, "0");
document.getElementById("seconds").textContent = seconds.toString().padStart(2, "0");
}
setInterval(updateCountdown, 1000);
updateCountdown(); // Run immediately

186
wwwroot/js/main.js Normal file
View File

@@ -0,0 +1,186 @@
(() => {
'use strict';
const navbar = document.querySelector('.navbar');
const emailInput = document.getElementById('email-input');
const notifyBtn = document.getElementById('notify-button');
const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Smooth scroll for anchor links
document.addEventListener(
'click',
(e) => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href === '#') return;
const target = document.querySelector(href);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
{ passive: false }
);
// Intersection Observer for fade-in animations
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
(entries, obs) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
obs.unobserve(entry.target);
}
}
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.fade-in').forEach((el) => {
observer.observe(el);
});
} else {
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('is-visible'));
}
// Newsletter card animations on load
window.addEventListener('load', () => {
document
.querySelectorAll('.newsletter-header, .newsletter-content')
.forEach((el, i) => {
el.style.transitionDelay = `${i * 0.2}s`;
el.classList.add('is-visible');
});
document.querySelectorAll('.newsletter-card').forEach((card, i) => {
card.style.transitionDelay = `${i * 0.1}s`;
card.classList.add('is-visible');
});
});
// Navbar scroll effect
let lastY = 0;
let ticking = false;
function onScroll() {
lastY = window.scrollY || window.pageYOffset;
if (!ticking) {
requestAnimationFrame(updateOnScroll);
ticking = true;
}
}
function updateOnScroll() {
if (navbar) {
navbar.classList.toggle('scrolled', lastY > 50);
}
const progressBar = document.querySelector('.reading-progress');
if (progressBar) {
const max = document.body.scrollHeight - window.innerHeight;
const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0;
progressBar.style.width = `${progress * 100}%`;
}
ticking = false;
}
window.addEventListener('scroll', onScroll, { passive: true });
updateOnScroll();
// Subscribe handler (hero + CTA)
function setupSubscribe(inputEl, btnEl) {
if (!btnEl || !inputEl) return;
let inFlight = false;
const controller = new AbortController();
btnEl.addEventListener('click', async () => {
const email = inputEl.value.trim();
if (!emailRE.test(email)) {
alert('Please enter a valid email address.');
inputEl.focus();
return;
}
if (inFlight) return;
inFlight = true;
btnEl.disabled = true;
try {
const res = await fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
});
let message = 'Thank you for subscribing!';
if (res.ok) {
const data = await res.json().catch(() => ({}));
message = data.message || message;
} else {
message = "Thanks! We'll notify you when we launch.";
}
alert(message);
inputEl.value = '';
} catch (err) {
console.error('Subscribe error:', err);
alert("Thanks! We'll notify you when we launch.");
inputEl.value = '';
} finally {
btnEl.disabled = false;
inFlight = false;
}
});
window.addEventListener('beforeunload', () => controller.abort(), {
passive: true,
});
}
// Setup both hero and CTA subscribe forms
setupSubscribe(emailInput, notifyBtn);
setupSubscribe(
document.getElementById('cta-email-input'),
document.getElementById('cta-notify-button')
);
// Share newsletter utility
window.shareNewsletter = async function shareNewsletter() {
try {
if (navigator.share) {
await navigator.share({
title: document.title,
text: 'Check out this newsletter from RideAware',
url: location.href,
});
return;
}
} catch (err) {
console.warn('navigator.share error/cancel:', err);
}
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(location.href);
alert('Newsletter URL copied to clipboard!');
return;
} catch {
/* fall through */
}
}
const tmp = document.createElement('input');
tmp.value = location.href;
document.body.appendChild(tmp);
tmp.select();
document.execCommand('copy');
document.body.removeChild(tmp);
alert('Newsletter URL copied to clipboard!');
};
})();

186
wwwroot/js/main.min.js vendored Normal file
View File

@@ -0,0 +1,186 @@
(() => {
'use strict';
const navbar = document.querySelector('.navbar');
const emailInput = document.getElementById('email-input');
const notifyBtn = document.getElementById('notify-button');
const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Smooth scroll for anchor links
document.addEventListener(
'click',
(e) => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href === '#') return;
const target = document.querySelector(href);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
{ passive: false }
);
// Intersection Observer for fade-in animations
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
(entries, obs) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
obs.unobserve(entry.target);
}
}
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.fade-in').forEach((el) => {
observer.observe(el);
});
} else {
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('is-visible'));
}
// Newsletter card animations on load
window.addEventListener('load', () => {
document
.querySelectorAll('.newsletter-header, .newsletter-content')
.forEach((el, i) => {
el.style.transitionDelay = `${i * 0.2}s`;
el.classList.add('is-visible');
});
document.querySelectorAll('.newsletter-card').forEach((card, i) => {
card.style.transitionDelay = `${i * 0.1}s`;
card.classList.add('is-visible');
});
});
// Navbar scroll effect
let lastY = 0;
let ticking = false;
function onScroll() {
lastY = window.scrollY || window.pageYOffset;
if (!ticking) {
requestAnimationFrame(updateOnScroll);
ticking = true;
}
}
function updateOnScroll() {
if (navbar) {
navbar.classList.toggle('scrolled', lastY > 50);
}
const progressBar = document.querySelector('.reading-progress');
if (progressBar) {
const max = document.body.scrollHeight - window.innerHeight;
const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0;
progressBar.style.width = `${progress * 100}%`;
}
ticking = false;
}
window.addEventListener('scroll', onScroll, { passive: true });
updateOnScroll();
// Subscribe handler (hero + CTA)
function setupSubscribe(inputEl, btnEl) {
if (!btnEl || !inputEl) return;
let inFlight = false;
const controller = new AbortController();
btnEl.addEventListener('click', async () => {
const email = inputEl.value.trim();
if (!emailRE.test(email)) {
alert('Please enter a valid email address.');
inputEl.focus();
return;
}
if (inFlight) return;
inFlight = true;
btnEl.disabled = true;
try {
const res = await fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
});
let message = 'Thank you for subscribing!';
if (res.ok) {
const data = await res.json().catch(() => ({}));
message = data.message || message;
} else {
message = "Thanks! We'll notify you when we launch.";
}
alert(message);
inputEl.value = '';
} catch (err) {
console.error('Subscribe error:', err);
alert("Thanks! We'll notify you when we launch.");
inputEl.value = '';
} finally {
btnEl.disabled = false;
inFlight = false;
}
});
window.addEventListener('beforeunload', () => controller.abort(), {
passive: true,
});
}
// Setup both hero and CTA subscribe forms
setupSubscribe(emailInput, notifyBtn);
setupSubscribe(
document.getElementById('cta-email-input'),
document.getElementById('cta-notify-button')
);
// Share newsletter utility
window.shareNewsletter = async function shareNewsletter() {
try {
if (navigator.share) {
await navigator.share({
title: document.title,
text: 'Check out this newsletter from RideAware',
url: location.href,
});
return;
}
} catch (err) {
console.warn('navigator.share error/cancel:', err);
}
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(location.href);
alert('Newsletter URL copied to clipboard!');
return;
} catch {
/* fall through */
}
}
const tmp = document.createElement('input');
tmp.value = location.href;
document.body.appendChild(tmp);
tmp.select();
document.execCommand('copy');
document.body.removeChild(tmp);
alert('Newsletter URL copied to clipboard!');
};
})();