rewrite number whatever to .net and blazor.
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
/subscribers.db
|
||||
/.venv/
|
||||
/.github/
|
||||
/__pycache__/
|
||||
/.env
|
||||
venv
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
@@ -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
21
Data/AppDbContext.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
62
Middleware/SecurityMiddleware.cs
Normal file
62
Middleware/SecurityMiddleware.cs
Normal 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
31
Models/ContactMessage.cs
Normal 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
23
Models/Newsletter.cs
Normal 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
16
Models/Subscriber.cs
Normal 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
310
Pages/About.cshtml
Normal 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 & 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">∞</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
10
Pages/About.cshtml.cs
Normal 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
224
Pages/Contact.cshtml
Normal 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
158
Pages/Contact.cshtml.cs
Normal 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
26
Pages/Error.cshtml
Normal 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
27
Pages/Error.cshtml.cs
Normal 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
262
Pages/Index.cshtml
Normal 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 & 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 & 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
10
Pages/Index.cshtml.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace landing.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
101
Pages/NewsletterDetail.cshtml
Normal file
101
Pages/NewsletterDetail.cshtml
Normal 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>
|
||||
}
|
||||
28
Pages/NewsletterDetail.cshtml.cs
Normal file
28
Pages/NewsletterDetail.cshtml.cs
Normal 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
78
Pages/Newsletters.cshtml
Normal 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>
|
||||
25
Pages/Newsletters.cshtml.cs
Normal file
25
Pages/Newsletters.cshtml.cs
Normal 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
125
Pages/Shared/_Layout.cshtml
Normal 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>© 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
2
Pages/Subscribe.cshtml
Normal file
@@ -0,0 +1,2 @@
|
||||
@page
|
||||
@model SubscribeModel
|
||||
76
Pages/Subscribe.cshtml.cs
Normal file
76
Pages/Subscribe.cshtml.cs
Normal 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
7
Pages/Unsubscribe.cshtml
Normal file
@@ -0,0 +1,7 @@
|
||||
@page
|
||||
@model UnsubscribeModel
|
||||
@{
|
||||
ViewData["Title"] = "Unsubscribe";
|
||||
Layout = null;
|
||||
}
|
||||
@Model.ResultMessage
|
||||
45
Pages/Unsubscribe.cshtml.cs
Normal file
45
Pages/Unsubscribe.cshtml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
4
Pages/_ViewImports.cshtml
Normal file
4
Pages/_ViewImports.cshtml
Normal file
@@ -0,0 +1,4 @@
|
||||
@using landing
|
||||
@using landing.Models
|
||||
@namespace landing.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
Pages/_ViewStart.cshtml
Normal file
3
Pages/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
74
Program.cs
Normal file
74
Program.cs
Normal 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}");
|
||||
29
Properties/launchSettings.json
Normal file
29
Properties/launchSettings.json
Normal 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
98
Services/EmailService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
221
Services/SpamDetectionService.cs
Normal file
221
Services/SpamDetectionService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
appsettings.Development.json
Normal file
9
appsettings.Development.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"DetailedErrors": true,
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
appsettings.json
Normal file
22
appsettings.json
Normal 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"
|
||||
}
|
||||
@@ -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
18
go.mod
@@ -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
32
go.sum
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
14
landing.csproj
Normal 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>
|
||||
190
scripts/build.sh
190
scripts/build.sh
@@ -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
BIN
wwwroot/assets/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
1
wwwroot/assets/RideAwareLogo.svg
Normal file
1
wwwroot/assets/RideAwareLogo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
BIN
wwwroot/assets/apple-touch-icon.png
Normal file
BIN
wwwroot/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
wwwroot/assets/logo.png
Normal file
BIN
wwwroot/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
1
wwwroot/assets/undraw_indoor-bike_9lxj.svg
Normal file
1
wwwroot/assets/undraw_indoor-bike_9lxj.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
1924
wwwroot/css/styles.css
Normal file
1924
wwwroot/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
25
wwwroot/js/countdown.js
Normal file
25
wwwroot/js/countdown.js
Normal 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
186
wwwroot/js/main.js
Normal 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
186
wwwroot/js/main.min.js
vendored
Normal 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!');
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user