feat(api,media,auth): Add video upload support and improve media handling

- Add video upload support with separate size limits (100MB for videos, 10MB for images)
- Support MP4, WebM, and MOV video formats alongside existing image formats
- Add video styling to post cards and compose component with proper object-fit
- Improve storage URL configuration with fallback error handling for missing endpoints
- Auto-enable NSFW settings when importing accounts from NSFW nodes
- Add age verification requirement for importing accounts on NSFW nodes
- Allow empty string for avatar and header URLs in profile update validation
- Update login page to display node name in browser title
- Enhance media upload validation with type-specific error messages
This commit is contained in:
AskIt
2026-01-26 02:26:16 +01:00
parent 981441bfe5
commit af93ac8f47
10 changed files with 145 additions and 36 deletions
+15 -1
View File
@@ -6,7 +6,7 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows } from '@/db'; import { db, users, posts, media, follows, nodes } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -191,6 +191,20 @@ export async function POST(req: NextRequest) {
postsCount: importPosts.length, postsCount: importPosts.length,
}).returning(); }).returning();
// Check if this is an NSFW node and auto-enable NSFW settings
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
});
if (node?.isNsfw) {
await db.update(users)
.set({
nsfwEnabled: true,
isNsfw: true
})
.where(eq(users.id, newUser.id));
}
// Import posts // Import posts
let importedPosts = 0; let importedPosts = 0;
for (const post of importPosts) { for (const post of importPosts) {
+2 -2
View File
@@ -7,8 +7,8 @@ import { z } from 'zod';
const updateProfileSchema = z.object({ const updateProfileSchema = z.object({
displayName: z.string().min(1).max(50).optional(), displayName: z.string().min(1).max(50).optional(),
bio: z.string().max(160).optional().nullable(), bio: z.string().max(160).optional().nullable(),
avatarUrl: z.string().url().optional().nullable(), avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
headerUrl: z.string().url().optional().nullable(), headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
website: z.string().url().or(z.string().length(0)).optional().nullable(), website: z.string().url().or(z.string().length(0)).optional().nullable(),
}); });
+16 -8
View File
@@ -4,8 +4,10 @@ import { requireAuth } from '@/lib/auth';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 100MB for videos
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -20,16 +22,20 @@ export async function POST(req: NextRequest) {
} }
// Validate file type // Validate file type
if (!ALLOWED_TYPES.includes(file.type)) { const isImage = ALLOWED_IMAGE_TYPES.includes(file.type);
const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type);
if (!isImage && !isVideo) {
return NextResponse.json({ return NextResponse.json({
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP' error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV'
}, { status: 400 }); }, { status: 400 });
} }
// Validate file size // Validate file size based on type
if (file.size > MAX_FILE_SIZE) { const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
if (file.size > maxSize) {
return NextResponse.json({ return NextResponse.json({
error: 'File too large. Maximum size: 10MB' error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}`
}, { status: 400 }); }, { status: 400 });
} }
@@ -62,8 +68,10 @@ export async function POST(req: NextRequest) {
let url = ''; let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) { if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`; url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else { } else if (process.env.STORAGE_ENDPOINT) {
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`; url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
} }
// Store media record // Store media record
+3 -3
View File
@@ -39,10 +39,10 @@ export async function POST(req: NextRequest) {
let url = ''; let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) { if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`; url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else { } else if (process.env.STORAGE_ENDPOINT) {
// Fallback if no public URL configured (e.g. valid for some providers)
// This might need adjustment based on provider
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`; url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
} }
return NextResponse.json({ url }); return NextResponse.json({ url });
+16
View File
@@ -347,6 +347,15 @@ a.btn-primary:visited {
display: block; display: block;
} }
.post-media-item video {
width: 100%;
height: 100%;
max-height: 360px;
object-fit: cover;
display: block;
cursor: pointer;
}
.video-embed-container { .video-embed-container {
position: relative; position: relative;
padding-bottom: 56.25%; padding-bottom: 56.25%;
@@ -532,6 +541,13 @@ a.btn-primary:visited {
display: block; display: block;
} }
.compose-media-item video {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.compose-media-remove { .compose-media-remove {
position: absolute; position: absolute;
top: 6px; top: 6px;
+35 -1
View File
@@ -24,6 +24,7 @@ export default function LoginPage() {
const [importPassword, setImportPassword] = useState(''); const [importPassword, setImportPassword] = useState('');
const [importHandle, setImportHandle] = useState(''); const [importHandle, setImportHandle] = useState('');
const [acceptedCompliance, setAcceptedCompliance] = useState(false); const [acceptedCompliance, setAcceptedCompliance] = useState(false);
const [importAgeVerified, setImportAgeVerified] = useState(false);
const [importSuccess, setImportSuccess] = useState<string | null>(null); const [importSuccess, setImportSuccess] = useState<string | null>(null);
// Fetch node info // Fetch node info
@@ -37,6 +38,10 @@ export default function LoginPage() {
logoUrl: data.logoUrl || undefined, logoUrl: data.logoUrl || undefined,
isNsfw: data.isNsfw || false isNsfw: data.isNsfw || false
}); });
// Update page title
if (data.name && data.name !== 'Synapsis') {
document.title = data.name;
}
setNodeInfoLoaded(true); setNodeInfoLoaded(true);
}) })
.catch(() => { .catch(() => {
@@ -77,6 +82,11 @@ export default function LoginPage() {
return; return;
} }
if (nodeInfo.isNsfw && !importAgeVerified) {
setError('You must verify your age to import an account on this node');
return;
}
setLoading(true); setLoading(true);
setError(''); setError('');
setImportSuccess(null); setImportSuccess(null);
@@ -564,11 +574,35 @@ export default function LoginPage() {
</label> </label>
</div> </div>
{nodeInfo.isNsfw && (
<div style={{
marginBottom: '20px',
padding: '12px',
background: 'rgba(239, 68, 68, 0.05)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 'var(--radius-md)',
}}>
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={importAgeVerified}
onChange={(e) => setImportAgeVerified(e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
<strong style={{ color: 'var(--error)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<TriangleAlert size={12} /> Age Verification:
</strong> This node contains adult or sensitive content. I confirm that I am at least 18 years of age.
</span>
</label>
</div>
)}
<button <button
type="submit" type="submit"
className="btn btn-primary btn-lg" className="btn btn-primary btn-lg"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance} disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
> >
{loading ? 'Importing...' : 'Import Account'} {loading ? 'Importing...' : 'Import Account'}
</button> </button>
+28 -16
View File
@@ -3,10 +3,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AutoTextarea from '@/components/AutoTextarea'; import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types'; import { Post, Attachment } from '@/lib/types';
import { ImageIcon, AlertTriangle } 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 { formatFullHandle } from '@/lib/utils/handle';
interface MediaAttachment extends Attachment {
mimeType?: string;
}
interface ComposeProps { interface ComposeProps {
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void; onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void;
replyingTo?: Post | null; replyingTo?: Post | null;
@@ -18,7 +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 [content, setContent] = useState(''); const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [linkPreview, setLinkPreview] = useState<any>(null); const [linkPreview, setLinkPreview] = useState<any>(null);
@@ -97,7 +101,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
setUploadError(null); setUploadError(null);
setIsUploading(true); setIsUploading(true);
const uploaded: Attachment[] = []; const uploaded: MediaAttachment[] = [];
for (const file of selectedFiles) { for (const file of selectedFiles) {
try { try {
@@ -117,6 +121,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
id: data.media.id, id: data.media.id,
url: data.media.url || data.url, url: data.media.url || data.url,
altText: data.media.altText ?? null, altText: data.media.altText ?? null,
mimeType: data.media.mimeType ?? file.type,
}); });
} catch (error) { } catch (error) {
console.error('Upload failed', error); console.error('Upload failed', error);
@@ -153,18 +158,25 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
/> />
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="compose-media-grid"> <div className="compose-media-grid">
{attachments.map((item) => ( {attachments.map((item) => {
<div className="compose-media-item" key={item.id}> const isVideo = item.mimeType?.startsWith('video/');
<img src={item.url} alt={item.altText || 'Upload preview'} /> return (
<button <div className="compose-media-item" key={item.id}>
type="button" {isVideo ? (
className="compose-media-remove" <video src={item.url} muted playsInline preload="metadata" />
onClick={() => handleRemoveAttachment(item.id)} ) : (
> <img src={item.url} alt={item.altText || 'Upload preview'} />
x )}
</button> <button
</div> type="button"
))} className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
);
})}
</div> </div>
)} )}
@@ -219,7 +231,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
{isUploading ? '...' : <ImageIcon size={20} />} {isUploading ? '...' : <ImageIcon size={20} />}
<input <input
type="file" type="file"
accept="image/*" accept="image/*,video/mp4,video/webm,video/quicktime"
multiple multiple
onChange={handleMediaSelect} onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4} disabled={isUploading || attachments.length >= 4}
+25 -5
View File
@@ -511,11 +511,31 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
{post.media && post.media.length > 0 && ( {post.media && post.media.length > 0 && (
<div className="post-media-grid"> <div className="post-media-grid">
{post.media.map((item) => ( {post.media.map((item) => {
<div className="post-media-item" key={item.id}> const isVideo = item.mimeType?.startsWith('video/');
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" /> return (
</div> <div className="post-media-item" key={item.id}>
))} {isVideo ? (
<video
src={item.url}
autoPlay
muted
loop
playsInline
preload="metadata"
onClick={(e) => {
e.stopPropagation();
const video = e.currentTarget;
video.muted = !video.muted;
}}
title="Click to toggle sound"
/>
) : (
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
)}
</div>
);
})}
</div> </div>
)} )}
+4
View File
@@ -43,6 +43,10 @@ export function AccentColorProvider({ children }: { children: ReactNode }) {
setAccentColor(data.accentColor); setAccentColor(data.accentColor);
applyAccentColor(data.accentColor); applyAccentColor(data.accentColor);
} }
// Update page title if node has a custom name
if (data?.name && data.name !== 'Synapsis') {
document.title = data.name;
}
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
+1
View File
@@ -26,6 +26,7 @@ export interface MediaItem {
id: string; id: string;
url: string; url: string;
altText?: string | null; altText?: string | null;
mimeType?: string | null;
} }
export interface Attachment { export interface Attachment {