Age gating: birth month/year in registration, under-16 login block, under-18 NSFW block

This commit is contained in:
Patrick Britton 2026-02-06 20:56:00 -06:00
parent 256592379a
commit b10595f252
8 changed files with 166 additions and 15 deletions

View file

@ -43,6 +43,8 @@ type RegisterRequest struct {
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
EmailNewsletter bool `json:"email_newsletter"`
EmailContact bool `json:"email_contact"`
BirthMonth int `json:"birth_month" binding:"required,min=1,max=12"`
BirthYear int `json:"birth_year" binding:"required,min=1900,max=2025"`
}
type LoginRequest struct {
@ -144,6 +146,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
ID: userID,
Handle: &req.Handle,
DisplayName: &req.DisplayName,
BirthMonth: req.BirthMonth,
BirthYear: req.BirthYear,
}
if err := h.repo.CreateProfile(c.Request.Context(), profile); err != nil {
log.Printf("[Auth] Failed to create profile for %s: %v. Rolling back user.", user.ID, err)
@ -232,6 +236,25 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Age gate: check if user is under 16
var profile *models.Profile
profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
if profile != nil && profile.BirthYear > 0 {
now := time.Now()
age := now.Year() - profile.BirthYear
if int(now.Month()) < profile.BirthMonth {
age--
}
if age < 16 {
log.Printf("[Auth] Login blocked for underage user %s (age %d)", req.Email, age)
c.JSON(http.StatusForbidden, gin.H{
"error": "You must be at least 16 years old to use Sojorn. Please come back when you're older!",
"code": "age_restricted",
})
return
}
}
if user.Status == models.UserStatusPending {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email verification required", "code": "verify_email"})
return
@ -270,10 +293,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
refreshToken, _ := generateRandomString(32)
_ = h.repo.StoreRefreshToken(c.Request.Context(), user.ID.String(), refreshToken, 30*24*time.Hour)
profile, err := h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
if err != nil {
log.Printf("[Auth] Failed to get profile for %s: %v", user.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile", "details": err.Error()})
// Re-fetch profile if not already loaded from age check
if profile == nil {
profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
}
if profile == nil {
log.Printf("[Auth] Failed to get profile for %s", user.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile"})
return
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -82,6 +83,25 @@ func (h *SettingsHandler) UpdateUserSettings(c *gin.Context) {
}
us.UserID = userID
// Block NSFW toggle for users under 18
if us.NSFWEnabled != nil && *us.NSFWEnabled {
profile, err := h.userRepo.GetProfileByID(c.Request.Context(), userID.String())
if err == nil && profile != nil && profile.BirthYear > 0 {
now := time.Now()
age := now.Year() - profile.BirthYear
if int(now.Month()) < profile.BirthMonth {
age--
}
if age < 18 {
c.JSON(http.StatusForbidden, gin.H{
"error": "You must be at least 18 years old to enable sensitive content. This is required by law in most jurisdictions.",
"code": "age_restricted_nsfw",
})
return
}
}
}
if err := h.userRepo.UpdateUserSettings(c.Request.Context(), &us); err != nil {
log.Error().Err(err).Msg("Failed to update user settings")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings", "details": err.Error()})

View file

@ -51,6 +51,8 @@ type Profile struct {
EncryptedPrivateKey *string `json:"encrypted_private_key" db:"encrypted_private_key"`
HasCompletedOnboarding bool `json:"has_completed_onboarding" db:"has_completed_onboarding"`
Role string `json:"role" db:"role"`
BirthMonth int `json:"birth_month" db:"birth_month"`
BirthYear int `json:"birth_year" db:"birth_year"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`

View file

@ -29,10 +29,10 @@ func (r *UserRepository) Pool() *pgxpool.Pool {
func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Profile) error {
query := `
INSERT INTO public.profiles (id, handle, display_name, bio, origin_country)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO public.profiles (id, handle, display_name, bio, origin_country, birth_month, birth_year)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.pool.Exec(ctx, query, profile.ID, profile.Handle, profile.DisplayName, profile.Bio, profile.OriginCountry)
_, err := r.pool.Exec(ctx, query, profile.ID, profile.Handle, profile.DisplayName, profile.Bio, profile.OriginCountry, profile.BirthMonth, profile.BirthYear)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
@ -51,11 +51,11 @@ func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Prof
}
func (r *UserRepository) GetProfileByID(ctx context.Context, id string) (*models.Profile, error) {
query := `SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, is_official, is_private, role, created_at FROM public.profiles WHERE id = $1::uuid`
query := `SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, is_official, is_private, role, created_at, COALESCE(birth_month, 0), COALESCE(birth_year, 0) FROM public.profiles WHERE id = $1::uuid`
var p models.Profile
err := r.pool.QueryRow(ctx, query, id).Scan(
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt,
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt, &p.BirthMonth, &p.BirthYear,
)
if err != nil {
return nil, err
@ -66,7 +66,8 @@ func (r *UserRepository) GetProfileByID(ctx context.Context, id string) (*models
func (r *UserRepository) GetProfileByHandle(ctx context.Context, handle string) (*models.Profile, error) {
query := `
SELECT id, handle, display_name, bio, avatar_url, origin_country,
has_completed_onboarding, is_official, is_private, role, created_at
has_completed_onboarding, is_official, is_private, role, created_at,
COALESCE(birth_month, 0), COALESCE(birth_year, 0)
FROM public.profiles
WHERE handle = $1
`
@ -74,6 +75,7 @@ func (r *UserRepository) GetProfileByHandle(ctx context.Context, handle string)
err := r.pool.QueryRow(ctx, query, handle).Scan(
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry,
&p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt,
&p.BirthMonth, &p.BirthYear,
)
if err != nil {
return nil, err

View file

@ -21,6 +21,8 @@ class Profile {
final int? registrationId;
final String? encryptedPrivateKey;
final bool hasCompletedOnboarding;
final int birthMonth;
final int birthYear;
Profile({
required this.id,
@ -42,6 +44,8 @@ class Profile {
this.registrationId,
this.encryptedPrivateKey,
this.hasCompletedOnboarding = false,
this.birthMonth = 0,
this.birthYear = 0,
});
factory Profile.fromJson(Map<String, dynamic> json) {
@ -81,6 +85,8 @@ class Profile {
registrationId: json['registration_id'] as int?,
encryptedPrivateKey: json['encrypted_private_key'] as String?,
hasCompletedOnboarding: json['has_completed_onboarding'] as bool? ?? false,
birthMonth: json['birth_month'] as int? ?? 0,
birthYear: json['birth_year'] as int? ?? 0,
);
}
@ -105,6 +111,8 @@ class Profile {
'registration_id': registrationId,
'encrypted_private_key': encryptedPrivateKey,
'has_completed_onboarding': hasCompletedOnboarding,
'birth_month': birthMonth,
'birth_year': birthYear,
};
}
@ -128,6 +136,8 @@ class Profile {
bool? isPrivate,
bool? isOfficial,
bool? hasCompletedOnboarding,
int? birthMonth,
int? birthYear,
}) {
return Profile(
id: id ?? this.id,
@ -149,6 +159,8 @@ class Profile {
registrationId: registrationId ?? this.registrationId,
encryptedPrivateKey: encryptedPrivateKey ?? this.encryptedPrivateKey,
hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding,
birthMonth: birthMonth ?? this.birthMonth,
birthYear: birthYear ?? this.birthYear,
);
}
}

View file

@ -34,6 +34,10 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
// Email preferences (single combined option)
bool _emailUpdates = false;
// Age gate
int? _birthMonth;
int? _birthYear;
// Turnstile site key from environment or default production key
static const String _turnstileSiteKey = String.fromEnvironment(
'TURNSTILE_SITE_KEY',
@ -61,6 +65,14 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
return;
}
// Validate age gate
if (_birthMonth == null || _birthYear == null) {
setState(() {
_errorMessage = 'Please enter your date of birth';
});
return;
}
// Validate legal consent
if (!_acceptTerms) {
setState(() {
@ -93,6 +105,8 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
acceptPrivacy: _acceptPrivacy,
emailNewsletter: _emailUpdates,
emailContact: _emailUpdates,
birthMonth: _birthMonth!,
birthYear: _birthYear!,
);
if (mounted) {
@ -286,6 +300,63 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
),
const SizedBox(height: AppTheme.spacingLg),
// Age Gate - Birth Month & Year
Text(
'Date of Birth',
style: AppTheme.bodyMedium.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'You must be at least 16 to use Sojorn. Users under 18 cannot access sensitive content.',
style: AppTheme.textTheme.labelSmall?.copyWith(
color: AppTheme.navyText.withOpacity(0.6),
),
),
const SizedBox(height: AppTheme.spacingSm),
Row(
children: [
// Month dropdown
Expanded(
flex: 3,
child: DropdownButtonFormField<int>(
value: _birthMonth,
decoration: const InputDecoration(
labelText: 'Month',
prefixIcon: Icon(Icons.calendar_month),
),
items: List.generate(12, (i) {
final month = i + 1;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return DropdownMenuItem(value: month, child: Text(months[i]));
}),
onChanged: (v) => setState(() => _birthMonth = v),
validator: (v) => v == null ? 'Required' : null,
),
),
const SizedBox(width: AppTheme.spacingSm),
// Year dropdown
Expanded(
flex: 2,
child: DropdownButtonFormField<int>(
value: _birthYear,
decoration: const InputDecoration(
labelText: 'Year',
),
items: List.generate(
DateTime.now().year - 1900 + 1,
(i) {
final year = DateTime.now().year - i;
return DropdownMenuItem(value: year, child: Text('$year'));
},
),
onChanged: (v) => setState(() => _birthYear = v),
validator: (v) => v == null ? 'Required' : null,
),
),
],
),
const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA
Container(
decoration: BoxDecoration(

View file

@ -408,6 +408,16 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
final userSettings = state.user;
if (userSettings == null) return const SizedBox.shrink();
// Calculate age from profile
final profile = state.profile;
bool isUnder18 = false;
if (profile != null && profile.birthYear > 0) {
final now = DateTime.now();
int age = now.year - profile.birthYear;
if (now.month < profile.birthMonth) age--;
isUnder18 = age < 18;
}
return Container(
decoration: BoxDecoration(
color: AppTheme.cardSurface,
@ -434,12 +444,16 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Show Sensitive Content (NSFW)'),
subtitle: const Text(
'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.',
subtitle: Text(
isUnder18
? 'You must be at least 18 years old to enable this feature. This is required by law in most jurisdictions.'
: 'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.',
),
value: userSettings.nsfwEnabled,
activeColor: Colors.amber.shade700,
onChanged: (v) => ref.read(settingsProvider.notifier).updateUser(
onChanged: isUnder18
? null
: (v) => ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwEnabled: v),
),
),

View file

@ -263,6 +263,8 @@ class AuthService {
required bool acceptPrivacy,
bool emailNewsletter = false,
bool emailContact = false,
required int birthMonth,
required int birthYear,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
@ -279,6 +281,8 @@ class AuthService {
'accept_privacy': acceptPrivacy,
'email_newsletter': emailNewsletter,
'email_contact': emailContact,
'birth_month': birthMonth,
'birth_year': birthYear,
}),
);