feat: Introduce runtime configuration for domain and enhance S3 storage settings with public base URL and Contabo support.

This commit is contained in:
Christomatt
2026-02-03 01:27:46 +01:00
parent 91d122ca3a
commit 38ddbf8bc1
20 changed files with 244 additions and 48 deletions
+3
View File
@@ -72,6 +72,9 @@ services:
# Port configuration (auto or specific port) # Port configuration (auto or specific port)
PORT: ${PORT:-3000} PORT: ${PORT:-3000}
# S3 Storage public URL (for custom S3 providers like R2, B2, Contabo)
STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-}
# Node environment # Node environment
NODE_ENV: production NODE_ENV: production
volumes: volumes:
+2
View File
@@ -15,6 +15,7 @@ const registerSchema = z.object({
// S3-compatible storage credentials // S3-compatible storage credentials
storageProvider: z.string().min(1), storageProvider: z.string().min(1),
storageEndpoint: z.string().nullable().optional(), storageEndpoint: z.string().nullable().optional(),
storagePublicBaseUrl: z.string().nullable().optional(),
storageRegion: z.string().min(1), storageRegion: z.string().min(1),
storageBucket: z.string().min(1), storageBucket: z.string().min(1),
storageAccessKey: z.string().min(10), storageAccessKey: z.string().min(10),
@@ -71,6 +72,7 @@ export async function POST(request: Request) {
data.displayName, data.displayName,
data.storageProvider, data.storageProvider,
data.storageEndpoint || null, data.storageEndpoint || null,
data.storagePublicBaseUrl || null,
data.storageRegion, data.storageRegion,
data.storageBucket, data.storageBucket,
data.storageAccessKey, data.storageAccessKey,
+1
View File
@@ -78,6 +78,7 @@ export async function POST(request: Request) {
botAvatarUrl = await generateAndUploadAvatarToUserStorage( botAvatarUrl = await generateAndUploadAvatarToUserStorage(
botHandle, botHandle,
user.storageEndpoint || undefined, user.storageEndpoint || undefined,
user.storagePublicBaseUrl || undefined,
user.storageRegion || 'auto', user.storageRegion || 'auto',
user.storageBucket, user.storageBucket,
accessKeyId, accessKeyId,
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:3000',
});
}
+1
View File
@@ -64,6 +64,7 @@ export async function POST(req: NextRequest) {
file.type, file.type,
user.storageProvider as any, user.storageProvider as any,
user.storageEndpoint, user.storageEndpoint,
user.storagePublicBaseUrl,
user.storageRegion || 'us-east-1', user.storageRegion || 'us-east-1',
user.storageBucket || '', user.storageBucket || '',
user.storageAccessKeyEncrypted, user.storageAccessKeyEncrypted,
+3 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
interface Conversation { interface Conversation {
@@ -43,6 +43,7 @@ export default function ChatPage() {
// Chat Data State // Chat Data State
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null); const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const selectedHandle = selectedConversation ? useFormattedHandle(selectedConversation.participant2.handle) : '';
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -389,7 +390,7 @@ export default function ChatPage() {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div> <div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formatFullHandle(selectedConversation.participant2.handle)} {selectedHandle}
</div> </div>
</div> </div>
<button <button
+3 -2
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons'; import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
import { PostCard } from '@/components/PostCard'; import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types'; import { Post } from '@/lib/types';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot, Network, Server, EyeOff } from 'lucide-react'; import { Bot, Network, Server, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
@@ -21,6 +21,7 @@ interface User {
} }
function UserCard({ user }: { user: User }) { function UserCard({ user }: { user: User }) {
const fullHandle = useFormattedHandle(user.handle);
return ( return (
<Link href={`/u/${user.handle}`} className="user-card"> <Link href={`/u/${user.handle}`} className="user-card">
<div className="avatar"> <div className="avatar">
@@ -52,7 +53,7 @@ function UserCard({ user }: { user: User }) {
</span> </span>
)} )}
</div> </div>
<div className="user-card-handle">{formatFullHandle(user.handle)}</div> <div className="user-card-handle">{fullHandle}</div>
{user.bio && <div className="user-card-bio">{user.bio}</div>} {user.bio && <div className="user-card-bio">{user.bio}</div>}
</div> </div>
</Link> </Link>
+12 -9
View File
@@ -57,6 +57,7 @@ export const dynamic = 'force-dynamic';
import { AuthProvider } from '@/lib/contexts/AuthContext'; import { AuthProvider } from '@/lib/contexts/AuthContext';
import { ToastProvider } from '@/lib/contexts/ToastContext'; import { ToastProvider } from '@/lib/contexts/ToastContext';
import { AccentColorProvider } from '@/lib/contexts/AccentColorContext'; import { AccentColorProvider } from '@/lib/contexts/AccentColorContext';
import { ConfigProvider } from '@/lib/contexts/ConfigContext';
import { LayoutWrapper } from '@/components/LayoutWrapper'; import { LayoutWrapper } from '@/components/LayoutWrapper';
export default function RootLayout({ export default function RootLayout({
@@ -67,15 +68,17 @@ export default function RootLayout({
return ( return (
<html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}> <html lang="en" className={`${inter.variable} ${sairaCondensed.variable}`}>
<body> <body>
<AuthProvider> <ConfigProvider>
<AccentColorProvider> <AuthProvider>
<ToastProvider> <AccentColorProvider>
<LayoutWrapper> <ToastProvider>
{children} <LayoutWrapper>
</LayoutWrapper> {children}
</ToastProvider> </LayoutWrapper>
</AccentColorProvider> </ToastProvider>
</AuthProvider> </AccentColorProvider>
</AuthProvider>
</ConfigProvider>
</body> </body>
</html> </html>
); );
+45 -15
View File
@@ -34,6 +34,7 @@ export default function LoginPage() {
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [storageProvider, setStorageProvider] = useState('r2'); const [storageProvider, setStorageProvider] = useState('r2');
const [storageEndpoint, setStorageEndpoint] = useState(''); const [storageEndpoint, setStorageEndpoint] = useState('');
const [storagePublicBaseUrl, setStoragePublicBaseUrl] = useState('');
const [storageRegion, setStorageRegion] = useState('auto'); const [storageRegion, setStorageRegion] = useState('auto');
const [storageBucket, setStorageBucket] = useState(''); const [storageBucket, setStorageBucket] = useState('');
const [storageAccessKey, setStorageAccessKey] = useState(''); const [storageAccessKey, setStorageAccessKey] = useState('');
@@ -252,6 +253,7 @@ export default function LoginPage() {
displayName, displayName,
storageProvider, storageProvider,
storageEndpoint: storageEndpoint || null, storageEndpoint: storageEndpoint || null,
storagePublicBaseUrl: storagePublicBaseUrl || null,
storageRegion, storageRegion,
storageBucket, storageBucket,
storageAccessKey, storageAccessKey,
@@ -573,6 +575,7 @@ export default function LoginPage() {
<option value="r2">Cloudflare R2 (10GB free)</option> <option value="r2">Cloudflare R2 (10GB free)</option>
<option value="b2">Backblaze B2 (10GB free)</option> <option value="b2">Backblaze B2 (10GB free)</option>
<option value="wasabi">Wasabi</option> <option value="wasabi">Wasabi</option>
<option value="contabo">Contabo S3</option>
<option value="s3">AWS S3</option> <option value="s3">AWS S3</option>
<option value="minio">MinIO / Self-hosted</option> <option value="minio">MinIO / Self-hosted</option>
<option value="other">Other S3-compatible</option> <option value="other">Other S3-compatible</option>
@@ -611,22 +614,49 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{/* S3 Credentials - Full Width */} {/* Endpoint URL - only show for providers that need it (R2, B2, Contabo, MinIO, Other) */}
<div style={{ marginBottom: '16px' }}> {(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo' || storageProvider === 'minio' || storageProvider === 'other') && (
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}> <div style={{ marginBottom: '16px' }}>
Endpoint URL (optional for AWS) <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
</label> Endpoint URL
<input </label>
type="text" <input
className="input" type="text"
value={storageEndpoint} className="input"
onChange={(e) => setStorageEndpoint(e.target.value)} value={storageEndpoint}
placeholder="https://<account>.r2.cloudflarestorage.com" onChange={(e) => setStorageEndpoint(e.target.value)}
/> placeholder={storageProvider === 'contabo' ? 'https://s3.eu2.contabo.com' : 'https://<account>.r2.cloudflarestorage.com'}
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}> required
Leave empty for AWS S3. Required for R2, B2, MinIO. />
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
{storageProvider === 'contabo' ? 'S3 API endpoint from Contabo dashboard (e.g., https://s3.eu2.contabo.com)' :
'S3-compatible endpoint URL for uploads.'}
</div>
</div> </div>
</div> )}
{/* Public Base URL - only show for providers that need it (R2, B2, Contabo, Other) */}
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo' || storageProvider === 'other') && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Public Base URL
</label>
<input
type="text"
className="input"
value={storagePublicBaseUrl}
onChange={(e) => setStoragePublicBaseUrl(e.target.value)}
placeholder={storageProvider === 'contabo' ? 'https://usc1.contabostorage.com/d5bec82ae49b444d8314dcb13654dd1d:your-bucket' : 'https://pub-xxx.r2.dev'}
required
/>
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
{storageProvider === 'contabo' ? 'Public URL from Contabo (format: https://region.contabostorage.com/account-id:bucket)' :
storageProvider === 'r2' ? 'Public bucket URL from R2 dashboard (e.g., https://pub-xxx.r2.dev)' :
storageProvider === 'b2' ? 'Public bucket URL from B2 dashboard (e.g., https://f000.backblazeb2.com/file/bucket)' :
'Public URL where files are accessible (without trailing slash)'}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div> <div>
+3 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { PostCard } from '@/components/PostCard'; import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types'; import { Post } from '@/lib/types';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
@@ -65,6 +65,7 @@ const FlagIcon = () => (
); );
function UserCard({ user }: { user: User }) { function UserCard({ user }: { user: User }) {
const fullHandle = useFormattedHandle(user.handle);
return ( return (
<Link <Link
href={`/@${user.handle}`} href={`/@${user.handle}`}
@@ -107,7 +108,7 @@ function UserCard({ user }: { user: User }) {
</span> </span>
)} )}
</div> </div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>{formatFullHandle(user.handle)}</div> <div style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>{fullHandle}</div>
{user.bio && ( {user.bio && (
<div style={{ <div style={{
color: 'var(--foreground-secondary)', color: 'var(--foreground-secondary)',
+5 -3
View File
@@ -8,7 +8,7 @@ import { PostCard } from '@/components/PostCard';
import { User, Post } from '@/lib/types'; import { User, Post } from '@/lib/types';
import AutoTextarea from '@/components/AutoTextarea'; import AutoTextarea from '@/components/AutoTextarea';
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react'; import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
@@ -35,6 +35,7 @@ const stripHtml = (html: string | null | undefined): string | null => {
}; };
function UserRow({ user }: { user: UserSummary }) { function UserRow({ user }: { user: UserSummary }) {
const fullHandle = useFormattedHandle(user.handle);
return ( return (
<Link href={`/u/${user.handle}`} className="user-row"> <Link href={`/u/${user.handle}`} className="user-row">
<div className="avatar"> <div className="avatar">
@@ -66,7 +67,7 @@ function UserRow({ user }: { user: UserSummary }) {
</span> </span>
)} )}
</div> </div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div> <div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{fullHandle}</div>
{user.bio && stripHtml(user.bio) && ( {user.bio && stripHtml(user.bio) && (
<div className="user-row-bio">{stripHtml(user.bio)}</div> <div className="user-row-bio">{stripHtml(user.bio)}</div>
)} )}
@@ -82,6 +83,7 @@ export default function ProfilePage() {
const { isIdentityUnlocked, signUserAction } = useAuth(); const { isIdentityUnlocked, signUserAction } = useAuth();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [likedPosts, setLikedPosts] = useState<Post[]>([]); const [likedPosts, setLikedPosts] = useState<Post[]>([]);
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null); const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
@@ -719,7 +721,7 @@ export default function ProfilePage() {
{/* User Info */} {/* User Info */}
<div style={{ padding: '12px 0' }}> <div style={{ padding: '12px 0' }}>
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2> <h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
<p style={{ color: 'var(--foreground-tertiary)' }}>{formatFullHandle(user.handle)}</p> <p style={{ color: 'var(--foreground-tertiary)' }}>{userFullHandle}</p>
{user.bio && ( {user.bio && (
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p> <p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
+3 -2
View File
@@ -5,7 +5,7 @@ import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types'; import { Post, Attachment } from '@/lib/types';
import { ImageIcon, AlertTriangle, Film } from 'lucide-react'; import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
import { VideoEmbed } from '@/components/VideoEmbed'; import { VideoEmbed } from '@/components/VideoEmbed';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
interface MediaAttachment extends Attachment { interface MediaAttachment extends Attachment {
@@ -22,6 +22,7 @@ interface ComposeProps {
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) { export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
const { isIdentityUnlocked } = useAuth(); const { isIdentityUnlocked } = useAuth();
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]); const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
@@ -161,7 +162,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
{replyingTo && !isReply && ( {replyingTo && !isReply && (
<div className="compose-reply-target"> <div className="compose-reply-target">
<div className="compose-reply-info"> <div className="compose-reply-info">
Replying to <span className="compose-reply-handle">{formatFullHandle(replyingTo.author.handle)}</span> Replying to <span className="compose-reply-handle">{replyToHandle}</span>
</div> </div>
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}> <button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
Cancel Cancel
+10 -6
View File
@@ -10,7 +10,8 @@ import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext'; import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed'; import { VideoEmbed } from '@/components/VideoEmbed';
import BlurredVideo from '@/components/BlurredVideo'; import BlurredVideo from '@/components/BlurredVideo';
import { formatFullHandle, NODE_DOMAIN } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { useDomain } from '@/lib/contexts/ConfigContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
// Component for link preview image that hides on error // Component for link preview image that hides on error
@@ -52,6 +53,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const [reporting, setReporting] = useState(false); const [reporting, setReporting] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const domain = useDomain();
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
const replyToHandle = post.replyTo?.author?.handle ? useFormattedHandle(post.replyTo.author.handle) : '';
// Sync state if post changes (e.g. after a re-render from parent) // Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => { useEffect(() => {
@@ -263,7 +267,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
return post.author.handle; return post.author.handle;
} }
// If this is a swarm post from a DIFFERENT node, append the node domain // If this is a swarm post from a DIFFERENT node, append the node domain
if (post.nodeDomain && post.nodeDomain !== NODE_DOMAIN) { if (post.nodeDomain && post.nodeDomain !== domain) {
return `${post.author.handle}@${post.nodeDomain}`; return `${post.author.handle}@${post.nodeDomain}`;
} }
// Local user // Local user
@@ -403,7 +407,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}> <Link href={`/u/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle} {post.author.displayName || post.author.handle}
</Link> </Link>
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span> <span className="post-time">{authorHandle}</span>
</div> </div>
</div> </div>
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div> <div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
@@ -467,7 +471,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</span> </span>
)} )}
</div> </div>
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)} · {formatTime(post.createdAt)}</span> <span className="post-time">{authorHandle} · {formatTime(post.createdAt)}</span>
</div> </div>
{currentUser && currentUser.id !== post.author.id && ( {currentUser && currentUser.id !== post.author.id && (
<div style={{ position: 'relative', marginLeft: 'auto' }}> <div style={{ position: 'relative', marginLeft: 'auto' }}>
@@ -591,7 +595,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{effectiveReplyTo && !showThread && ( {effectiveReplyTo && !showThread && (
<div className="post-reply-to"> <div className="post-reply-to">
Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link> Replying to <Link href={`/u/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{replyToHandle}</Link>
</div> </div>
)} )}
@@ -672,7 +676,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
// Allow deleting own remote posts where handle might be username@node_domain // Allow deleting own remote posts where handle might be username@node_domain
(post.author.id.startsWith('swarm:') && ( (post.author.id.startsWith('swarm:') && (
post.author.handle === currentUser.handle || post.author.handle === currentUser.handle ||
post.author.handle === `${currentUser.handle}@${NODE_DOMAIN}` post.author.handle === `${currentUser.handle}@${domain}`
)) ))
)) && ( )) && (
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post"> <button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
+3 -2
View File
@@ -6,7 +6,7 @@ import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { formatFullHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { LogOut, Settings2 } from 'lucide-react'; import { LogOut, Settings2 } from 'lucide-react';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper // import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
@@ -18,6 +18,7 @@ export function Sidebar() {
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [unreadChatCount, setUnreadChatCount] = useState(0); const [unreadChatCount, setUnreadChatCount] = useState(0);
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
const formattedHandle = user ? useFormattedHandle(user.handle) : '';
useEffect(() => { useEffect(() => {
fetch('/api/node') fetch('/api/node')
@@ -188,7 +189,7 @@ export function Sidebar() {
</div> </div>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div> <div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div> <div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -65,8 +65,9 @@ export const users = pgTable('users', {
movedFrom: text('moved_from'), // Old actor URL if this account migrated here movedFrom: text('moved_from'), // Old actor URL if this account migrated here
migratedAt: timestamp('migrated_at'), // When the migration occurred migratedAt: timestamp('migrated_at'), // When the migration occurred
// User-owned S3-compatible storage - required for new users // User-owned S3-compatible storage - required for new users
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', etc storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', 'contabo', etc
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS) storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS)
storagePublicBaseUrl: text('storage_public_base_url'), // Public URL for viewing files (required for R2, B2, Contabo)
storageRegion: text('storage_region'), // Region (e.g., 'us-east-1') storageRegion: text('storage_region'), // Region (e.g., 'us-east-1')
storageBucket: text('storage_bucket'), // Bucket name storageBucket: text('storage_bucket'), // Bucket name
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key
+3
View File
@@ -139,6 +139,7 @@ export async function registerUser(
displayName?: string, displayName?: string,
storageProvider?: string, storageProvider?: string,
storageEndpoint?: string | null, storageEndpoint?: string | null,
storagePublicBaseUrl?: string | null,
storageRegion?: string, storageRegion?: string,
storageBucket?: string, storageBucket?: string,
storageAccessKey?: string, storageAccessKey?: string,
@@ -201,6 +202,7 @@ export async function registerUser(
const avatarUrl = await generateAndUploadAvatarToUserStorage( const avatarUrl = await generateAndUploadAvatarToUserStorage(
fullHandle, fullHandle,
storageEndpoint || undefined, storageEndpoint || undefined,
storagePublicBaseUrl || undefined,
storageRegion, storageRegion,
storageBucket, storageBucket,
storageAccessKey, storageAccessKey,
@@ -222,6 +224,7 @@ export async function registerUser(
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
storageProvider, storageProvider,
storageEndpoint: storageEndpoint || null, storageEndpoint: storageEndpoint || null,
storagePublicBaseUrl: storagePublicBaseUrl || null,
storageRegion, storageRegion,
storageBucket, storageBucket,
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey), storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
+33
View File
@@ -0,0 +1,33 @@
let cachedConfig: { domain: string } | null = null;
let configPromise: Promise<{ domain: string }> | null = null;
export async function getRuntimeConfig() {
if (cachedConfig) return cachedConfig;
if (configPromise) return configPromise;
configPromise = fetch('/api/config')
.then((res) => res.json())
.then((data) => {
cachedConfig = {
domain: data.domain || 'localhost:3000',
};
return cachedConfig;
})
.catch(() => {
cachedConfig = {
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000',
};
return cachedConfig;
});
return configPromise;
}
export function getDomain(): string {
return cachedConfig?.domain || process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
}
export function clearCachedConfig() {
cachedConfig = null;
configPromise = null;
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
interface RuntimeConfig {
domain: string;
}
interface ConfigContextType {
config: RuntimeConfig | null;
isLoading: boolean;
}
const ConfigContext = createContext<ConfigContextType>({
config: null,
isLoading: true,
});
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<RuntimeConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Fetch runtime config on mount
fetch('/api/config')
.then((res) => res.json())
.then((data) => {
setConfig({
domain: data.domain || 'localhost:3000',
});
})
.catch(() => {
// Fallback to build-time value if fetch fails
setConfig({
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000',
});
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<ConfigContext.Provider value={{ config, isLoading }}>
{children}
</ConfigContext.Provider>
);
}
export function useRuntimeConfig() {
const context = useContext(ConfigContext);
if (!context) {
throw new Error('useRuntimeConfig must be used within a ConfigProvider');
}
return context;
}
export function useDomain(): string {
const { config, isLoading } = useRuntimeConfig();
// Return runtime domain if loaded, otherwise fall back to build-time value
if (isLoading || !config) {
return process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
}
return config.domain;
}
+12 -2
View File
@@ -65,6 +65,7 @@ export async function uploadToUserStorage(
mimeType: string, mimeType: string,
provider: StorageProvider, provider: StorageProvider,
endpoint: string | null, endpoint: string | null,
publicBaseUrl: string | null,
region: string, region: string,
bucket: string, bucket: string,
encryptedAccessKey: string, encryptedAccessKey: string,
@@ -99,8 +100,12 @@ export async function uploadToUserStorage(
// Construct URL based on provider // Construct URL based on provider
let url: string; let url: string;
if (endpoint) {
// Custom endpoint (R2, MinIO, etc) // Priority: user's publicBaseUrl > construct from endpoint > AWS format
if (publicBaseUrl) {
url = `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
} else if (endpoint) {
// Custom endpoint (MinIO, etc)
url = `${endpoint}/${bucket}/${key}`; url = `${endpoint}/${bucket}/${key}`;
} else { } else {
// AWS S3 standard URL // AWS S3 standard URL
@@ -165,6 +170,7 @@ export async function testS3Credentials(
export async function generateAndUploadAvatarToUserStorage( export async function generateAndUploadAvatarToUserStorage(
handle: string, handle: string,
endpoint: string | undefined, endpoint: string | undefined,
publicBaseUrl: string | undefined,
region: string, region: string,
bucket: string, bucket: string,
accessKey: string, accessKey: string,
@@ -202,6 +208,10 @@ export async function generateAndUploadAvatarToUserStorage(
})); }));
// 3. Return URL // 3. Return URL
// Priority: user's publicBaseUrl > construct from endpoint > AWS format
if (publicBaseUrl) {
return `${publicBaseUrl.replace(/\/$/, '')}/${key}`;
}
if (endpoint) { if (endpoint) {
return `${endpoint}/${bucket}/${key}`; return `${endpoint}/${bucket}/${key}`;
} }
+28
View File
@@ -1,3 +1,6 @@
import { useDomain } from '@/lib/contexts/ConfigContext';
// Build-time domain fallback (for SSR/non-React contexts)
export const NODE_DOMAIN = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; export const NODE_DOMAIN = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
/** /**
@@ -23,3 +26,28 @@ export function formatFullHandle(handle: string, nodeDomain?: string | null): st
const domain = nodeDomain || NODE_DOMAIN; const domain = nodeDomain || NODE_DOMAIN;
return `@${cleanHandle}@${domain}`; return `@${cleanHandle}@${domain}`;
} }
/**
* React hook that formats a handle using the runtime domain config.
* Use this in client components instead of formatFullHandle for local handles.
*
* @param handle - The user handle (with or without domain)
* @param nodeDomain - Optional domain override for swarm posts
*/
export function useFormattedHandle(handle: string, nodeDomain?: string | null): string {
const runtimeDomain = useDomain();
if (!handle) return '';
// Remove leading @ if present for processing
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
// Check if it already has a domain (contains @)
if (cleanHandle.includes('@')) {
return `@${cleanHandle}`;
}
// Use provided domain (for remote posts) or fall back to runtime domain
const domain = nodeDomain || runtimeDomain;
return `@${cleanHandle}@${domain}`;
}