Age gating: birth month/year in registration, under-16 login block, under-18 NSFW block
This commit is contained in:
parent
256592379a
commit
b10595f252
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue