diff --git a/deploy/check-deploy-err.py b/deploy/check-deploy-err.py new file mode 100644 index 0000000..f1b2d5b --- /dev/null +++ b/deploy/check-deploy-err.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import paramiko + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("10.10.0.10", username="localadministrator", password="Bbt9115xty9176!", timeout=20, allow_agent=False, look_for_keys=False) + +_, o, e = c.exec_command( + "docker exec coolify-db psql -U coolify -d coolify -t -A -c " + '"SELECT id,status FROM application_deployment_queues ORDER BY id DESC LIMIT 3;"', + timeout=60, +) +o.channel.recv_exit_status() +print((o.read() + e.read()).decode()) + +_, o, e = c.exec_command( + "docker exec coolify-db psql -U coolify -d coolify -t -A -c " + '"SELECT logs FROM application_deployment_queues ORDER BY id DESC LIMIT 1;"', + timeout=120, +) +o.channel.recv_exit_status() +raw = (o.read() + e.read()).decode() +for needle in ["Failed to compile", "Type error", "error TS", "Module not found", "Cannot find", "Error:"]: + idx = raw.rfind(needle) + if idx >= 0: + print("\n===", needle, "===\n") + print(raw[idx : idx + 3500]) +c.close() \ No newline at end of file diff --git a/deploy/redeploy-poll.py b/deploy/redeploy-poll.py new file mode 100644 index 0000000..c8abfbe --- /dev/null +++ b/deploy/redeploy-poll.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import paramiko +import time + +HOST = "10.10.0.10" +USER = "localadministrator" +PASS = "Bbt9115xty9176!" +APP = "ira4bw4nhbvm87o7s3fgqu6v" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False) + +def run(cmd, t=300): + print("$", cmd[:160]) + _, o, e = c.exec_command(cmd, timeout=t) + code = o.channel.recv_exit_status() + out = (o.read() + e.read()).decode(errors="replace") + print(out[-4000:]) + print("exit", code, "\n") + return out, code + +run("cd /tmp/exposedgays && git fetch origin && git reset --hard origin/main && git log -1 --oneline") +run( + "TOKEN=$(cat /home/localadministrator/.coolify-token | tail -1); " + f"curl -sS -X POST 'http://127.0.0.1:8000/api/v1/deploy?uuid={APP}&force_rebuild=true' " + '-H "Authorization: Bearer $TOKEN"' +) + +for i in range(180): + out, _ = run("docker exec coolify php artisan queue:work --queue=high,default --once --timeout=900 2>&1", t=920) + if "ApplicationDeploymentJob" in out: + if "DONE" in out or "finished" in out.lower(): + print("=== DEPLOY SUCCESS ===") + break + if "FAIL" in out: + print("=== DEPLOY FAILED ===") + break + time.sleep(2) + +run('docker exec coolify-db psql -U coolify -d coolify -t -c "SELECT id,status FROM application_deployment_queues ORDER BY id DESC LIMIT 1;"') +run("curl -sS https://exposedgays.com/ 2>&1 | head -c 400") +c.close() \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 734ad03..99ffaa0 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -28,6 +28,7 @@ import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Pro import { usePrivacy } from "@/contexts/PrivacyContext"; import HideFromTraitsPanel from "@/components/HideFromTraitsPanel"; import BlockListPanel from "@/components/BlockListPanel"; +import PrivacyAudienceToggles from "@/components/PrivacyAudienceToggles"; import { BODY_TYPE_OPTIONS } from "@/lib/privacy-rules"; import { US_STATES, @@ -51,6 +52,8 @@ export default function ProfilePage() { unblockProfile, unblockByUsername, unblockAll, + privacySettings, + setPrivacySettings, } = usePrivacy(); const [authOpen, setAuthOpen] = useState(false); const [idModalOpen, setIdModalOpen] = useState(false); @@ -277,6 +280,11 @@ export default function ProfilePage() { )} + + (MOCK_CITY_MESSAGES); const [dmMessages, setDmMessages] = useState(MOCK_DM_MESSAGES); const [input, setInput] = useState(""); @@ -105,7 +114,29 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) { }; const messages = activeTab === "city" ? cityMessages : dmMessages; - const showComposer = activeTab === "city" || activeTab === "dm"; + + const dmTarget = dmUserId + ? MOCK_PROFILES.find((p) => p.id === dmUserId) ?? null + : null; + const viewer = authProfile ?? guestViewerProfile(); + const viewerId = user?.id ?? "guest"; + const dmAllowed = + !dmTarget || + canViewerMessageTarget( + viewer, + viewerId, + dmTarget, + loadHideFromRules(dmTarget.id), + blockList, + loadBlockList(dmTarget.id) + ); + const dmBlockNote = dmTarget + ? messageBlockReason(viewer, loadPrivacySettings(dmTarget.id)) + : null; + + const showComposer = + (activeTab === "city" || activeTab === "dm") && + (activeTab !== "dm" || dmAllowed); return (
@@ -139,12 +170,21 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {

- {dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"} + {dmTarget + ? dmTarget.display_name || dmTarget.username || `User #${dmUserId}` + : dmUserId + ? `Chat with User #${dmUserId}` + : "Private Messages"}

Use quick replies for address, directions & hosting notes

+ {!dmAllowed && dmBlockNote && ( +
+ {dmBlockNote} +
+ )}
diff --git a/src/components/CruiserProfileView.tsx b/src/components/CruiserProfileView.tsx index ce37194..1e22115 100644 --- a/src/components/CruiserProfileView.tsx +++ b/src/components/CruiserProfileView.tsx @@ -20,6 +20,11 @@ import { Button } from "@/components/ui/button"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { useAccess } from "@/components/Providers"; import { usePrivacy } from "@/contexts/PrivacyContext"; +import { useAuth } from "@/lib/auth/context"; +import { canViewerMessageTarget, guestViewerProfile } from "@/lib/visibility"; +import { loadHideFromRules } from "@/lib/privacy-rules"; +import { loadBlockList } from "@/lib/block-list"; +import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings"; import type { Profile } from "@/types"; import { formatStatsLine, formatDistance } from "@/lib/profile-stats"; import { timeAgo } from "@/lib/utils"; @@ -86,7 +91,8 @@ export function CruiserProfileView({ vanillaMode, }: CruiserProfileViewProps) { const { capabilities, blurExplicit } = useAccess(); - const { blockProfile, isBlocked, unblockProfile } = usePrivacy(); + const { user, profile: authProfile } = useAuth(); + const { blockProfile, isBlocked, unblockProfile, blockList } = usePrivacy(); const [tab, setTab] = useState("about"); const [blockMenuOpen, setBlockMenuOpen] = useState(false); const [activePhoto, setActivePhoto] = useState(0); @@ -123,9 +129,25 @@ export function CruiserProfileView({ const shouldBlurPhoto = (explicit: boolean) => vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit); + const viewer = authProfile ?? guestViewerProfile(); + const viewerId = user?.id ?? "guest"; + const canMessage = canViewerMessageTarget( + viewer, + viewerId, + profile, + loadHideFromRules(profile.id), + blockList, + loadBlockList(profile.id) + ); + const messageBlockedNote = messageBlockReason( + viewer, + loadPrivacySettings(profile.id) + ); + const handleChat = () => { if (!capabilities.canSendMessages) return; if (isBlocked(profile.id)) return; + if (!canMessage) return; onChat(profile); }; @@ -464,8 +486,14 @@ export function CruiserProfileView({ size="icon" className="rounded-full h-11 w-11 shrink-0" onClick={handleChat} - disabled={!capabilities.canSendMessages} - title={capabilities.canSendMessages ? "Chat" : "Sign up to chat"} + disabled={!capabilities.canSendMessages || !canMessage || isBlocked(profile.id)} + title={ + !capabilities.canSendMessages + ? "Sign up to chat" + : !canMessage && messageBlockedNote + ? messageBlockedNote + : "Chat" + } > diff --git a/src/components/Map.tsx b/src/components/Map.tsx index d2658dc..63cc3cd 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -20,7 +20,9 @@ import { profileMatchesAudienceView } from "@/lib/audience"; import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data"; import { useAuth } from "@/lib/auth/context"; import { usePrivacy } from "@/contexts/PrivacyContext"; -import { filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility"; +import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility"; +import { loadHideFromRules } from "@/lib/privacy-rules"; +import { loadBlockList } from "@/lib/block-list"; import { distanceMiles } from "@/lib/profile-stats"; import { cn } from "@/lib/utils"; @@ -183,10 +185,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length); }, [filteredProfiles, onActiveCountChange]); - const handleChat = useCallback((profile: Profile) => { - if (!capabilities.canSendMessages) return; - window.location.href = `/chat?user=${profile.id}`; - }, [capabilities.canSendMessages]); + const handleChat = useCallback( + (profile: Profile) => { + if (!capabilities.canSendMessages) return; + const allowed = canViewerMessageTarget( + viewer, + viewerId, + profile, + loadHideFromRules(profile.id), + blockList, + loadBlockList(profile.id) + ); + if (!allowed) return; + window.location.href = `/chat?user=${profile.id}`; + }, + [capabilities.canSendMessages, viewer, viewerId, blockList] + ); const togglePosition = (pos: Position) => { setFilters((f) => ({ diff --git a/src/components/PrivacyAudienceToggles.tsx b/src/components/PrivacyAudienceToggles.tsx new file mode 100644 index 0000000..6b1484f --- /dev/null +++ b/src/components/PrivacyAudienceToggles.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { Ghost, CameraOff } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import type { PrivacySettings } from '@/lib/privacy-settings'; + +interface PrivacyAudienceTogglesProps { + settings: PrivacySettings; + onChange: (settings: PrivacySettings) => void; +} + +function ToggleRow({ + title, + description, + hideLabel, + messageLabel, + hideOn, + messagesOn, + onHideChange, + onMessagesChange, +}: { + title: string; + description: string; + hideLabel: string; + messageLabel: string; + hideOn: boolean; + messagesOn: boolean; + onHideChange: (v: boolean) => void; + onMessagesChange: (v: boolean) => void; +}) { + return ( +
+
+

{title}

+

{description}

+
+
+ + +
+
+ + +
+
+ ); +} + +export default function PrivacyAudienceToggles({ + settings, + onChange, +}: PrivacyAudienceTogglesProps) { + const patch = (partial: Partial) => + onChange({ ...settings, ...partial }); + + return ( +
+

+ Audience filters +

+ + patch({ hide_from_anonymous: v })} + onMessagesChange={(v) => patch({ block_messages_from_anonymous: v })} + /> + +
+ + Applies to guests and anyone with "Browse anonymously" turned on. +
+ + patch({ hide_from_no_photo: v })} + onMessagesChange={(v) => patch({ block_messages_from_no_photo: v })} + /> + +
+ + Upload at least one photo in your gallery so others know you're not in this group. +
+
+ ); +} \ No newline at end of file diff --git a/src/contexts/PrivacyContext.tsx b/src/contexts/PrivacyContext.tsx index c11ec35..e31fd42 100644 --- a/src/contexts/PrivacyContext.tsx +++ b/src/contexts/PrivacyContext.tsx @@ -25,10 +25,18 @@ import { type BlockEntry, } from '@/lib/block-list'; import type { Profile } from '@/types'; +import { + DEFAULT_PRIVACY_SETTINGS, + loadPrivacySettings, + savePrivacySettings, + type PrivacySettings, +} from '@/lib/privacy-settings'; interface PrivacyContextValue { hideRules: HideFromRule[]; blockList: BlockEntry[]; + privacySettings: PrivacySettings; + setPrivacySettings: (settings: PrivacySettings) => void; addHideRule: (rule: Omit) => void; removeHideRule: (id: string) => void; blockProfile: (profile: Profile) => void; @@ -47,10 +55,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) { const [hideRules, setHideRules] = useState([]); const [blockList, setBlockList] = useState([]); + const [privacySettings, setPrivacySettingsState] = useState( + DEFAULT_PRIVACY_SETTINGS + ); const refresh = useCallback(() => { setHideRules(loadHideFromRules(userId)); setBlockList(loadBlockList(userId)); + setPrivacySettingsState(loadPrivacySettings(userId)); }, [userId]); useEffect(() => { @@ -60,14 +72,25 @@ export function PrivacyProvider({ children }: { children: ReactNode }) { useEffect(() => { const onHide = () => refresh(); const onBlock = () => refresh(); + const onSettings = () => refresh(); window.addEventListener('eg-hide-rules', onHide); window.addEventListener('eg-block-list', onBlock); + window.addEventListener('eg-privacy-settings', onSettings); return () => { window.removeEventListener('eg-hide-rules', onHide); window.removeEventListener('eg-block-list', onBlock); + window.removeEventListener('eg-privacy-settings', onSettings); }; }, [refresh]); + const setPrivacySettings = useCallback( + (settings: PrivacySettings) => { + savePrivacySettings(settings, userId); + setPrivacySettingsState(settings); + }, + [userId] + ); + const addHideRule = useCallback( (rule: Omit) => { const next = addHideFromRule(rule, userId); @@ -122,6 +145,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) { () => ({ hideRules, blockList, + privacySettings, + setPrivacySettings, addHideRule, removeHideRule, blockProfile, @@ -134,6 +159,8 @@ export function PrivacyProvider({ children }: { children: ReactNode }) { [ hideRules, blockList, + privacySettings, + setPrivacySettings, addHideRule, removeHideRule, blockProfile, diff --git a/src/lib/privacy-settings.ts b/src/lib/privacy-settings.ts new file mode 100644 index 0000000..960724b --- /dev/null +++ b/src/lib/privacy-settings.ts @@ -0,0 +1,91 @@ +import type { Profile } from '@/types'; + +const LS_SETTINGS = 'eg_privacy_settings'; +const LS_SETTINGS_USER = (userId: string) => `eg_privacy_settings_${userId}`; + +export interface PrivacySettings { + /** Anonymous / unnamed browsers cannot see you on map, search, or rails. */ + hide_from_anonymous: boolean; + /** Anonymous users cannot send you DMs. */ + block_messages_from_anonymous: boolean; + /** Profiles with no real photo cannot see you. */ + hide_from_no_photo: boolean; + /** Profiles with no real photo cannot message you. */ + block_messages_from_no_photo: boolean; +} + +export const DEFAULT_PRIVACY_SETTINGS: PrivacySettings = { + hide_from_anonymous: false, + block_messages_from_anonymous: false, + hide_from_no_photo: false, + block_messages_from_no_photo: false, +}; + +const PLACEHOLDER_HINTS = ['placehold.co', 'text=?', 'text=%3f', 'text=%3F']; + +export function profileHasPhoto(profile: Profile): boolean { + if (profile.gallery?.some((g) => g.url?.trim())) return true; + const url = profile.avatar_url?.trim(); + if (!url) return false; + const lower = url.toLowerCase(); + return !PLACEHOLDER_HINTS.some((hint) => lower.includes(hint)); +} + +/** Guest browse + profiles marked anonymous. */ +export function isAnonymousProfile(profile: Profile): boolean { + return profile.id === 'guest' || profile.is_anonymous; +} + +export function loadPrivacySettings(userId?: string | null): PrivacySettings { + if (typeof window === 'undefined') return { ...DEFAULT_PRIVACY_SETTINGS }; + try { + const key = userId ? LS_SETTINGS_USER(userId) : LS_SETTINGS; + const raw = localStorage.getItem(key); + if (!raw) return { ...DEFAULT_PRIVACY_SETTINGS }; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_PRIVACY_SETTINGS, ...parsed }; + } catch { + return { ...DEFAULT_PRIVACY_SETTINGS }; + } +} + +export function savePrivacySettings( + settings: PrivacySettings, + userId?: string | null +): void { + if (typeof window === 'undefined') return; + const key = userId ? LS_SETTINGS_USER(userId) : LS_SETTINGS; + localStorage.setItem(key, JSON.stringify(settings)); + window.dispatchEvent(new CustomEvent('eg-privacy-settings', { detail: settings })); +} + +export function viewerHiddenByPrivacySettings( + viewer: Profile, + settings: PrivacySettings +): boolean { + if (settings.hide_from_anonymous && isAnonymousProfile(viewer)) return true; + if (settings.hide_from_no_photo && !profileHasPhoto(viewer)) return true; + return false; +} + +export function viewerMessagesBlockedByPrivacySettings( + viewer: Profile, + settings: PrivacySettings +): boolean { + if (settings.block_messages_from_anonymous && isAnonymousProfile(viewer)) return true; + if (settings.block_messages_from_no_photo && !profileHasPhoto(viewer)) return true; + return false; +} + +export function messageBlockReason( + viewer: Profile, + settings: PrivacySettings +): string | null { + if (settings.block_messages_from_anonymous && isAnonymousProfile(viewer)) { + return 'This cruiser does not accept messages from anonymous profiles.'; + } + if (settings.block_messages_from_no_photo && !profileHasPhoto(viewer)) { + return 'This cruiser only accepts messages from profiles with photos.'; + } + return null; +} \ No newline at end of file diff --git a/src/lib/visibility.ts b/src/lib/visibility.ts index 16bae71..1e4a867 100644 --- a/src/lib/visibility.ts +++ b/src/lib/visibility.ts @@ -3,6 +3,11 @@ import type { BlockEntry } from "@/lib/block-list"; import { isBlocked, loadBlockList } from "@/lib/block-list"; import type { HideFromRule } from "@/lib/privacy-rules"; import { loadHideFromRules, viewerHiddenByRules } from "@/lib/privacy-rules"; +import { + loadPrivacySettings, + viewerHiddenByPrivacySettings, + viewerMessagesBlockedByPrivacySettings, +} from "@/lib/privacy-settings"; export interface VisibilityContext { /** The browsing user's profile (or guest stand-in). */ @@ -36,6 +41,28 @@ export function canViewerSeeTarget( if (viewer && viewerHiddenByRules(viewer, myHideRules)) return false; + const targetPrivacy = loadPrivacySettings(target.id); + if (viewer && viewerHiddenByPrivacySettings(viewer, targetPrivacy)) return false; + + return true; +} + +/** Can `viewer` open a DM thread with `target`? */ +export function canViewerMessageTarget( + viewer: Profile | null, + viewerId: string | null, + target: Profile, + myHideRules: HideFromRule[], + myBlockList: BlockEntry[], + targetBlockList: BlockEntry[] = [] +): boolean { + if (!canViewerSeeTarget(viewer, viewerId, target, myHideRules, myBlockList, targetBlockList)) { + return false; + } + if (viewer) { + const targetPrivacy = loadPrivacySettings(target.id); + if (viewerMessagesBlockedByPrivacySettings(viewer, targetPrivacy)) return false; + } return true; } diff --git a/supabase/migration_v5_privacy.sql b/supabase/migration_v5_privacy.sql index bcc47f3..c11fce0 100644 --- a/supabase/migration_v5_privacy.sql +++ b/supabase/migration_v5_privacy.sql @@ -3,7 +3,11 @@ ALTER TABLE profiles ADD COLUMN IF NOT EXISTS height_in integer, ADD COLUMN IF NOT EXISTS weight_lb integer, - ADD COLUMN IF NOT EXISTS body_type text; + ADD COLUMN IF NOT EXISTS body_type text, + ADD COLUMN IF NOT EXISTS hide_from_anonymous boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS block_messages_from_anonymous boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS hide_from_no_photo boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS block_messages_from_no_photo boolean NOT NULL DEFAULT false; CREATE TABLE IF NOT EXISTS hide_from_rules ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(),