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 { db, users, posts, media, follows } from '@/db';
import { db, users, posts, media, follows, nodes } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid';
@@ -191,6 +191,20 @@ export async function POST(req: NextRequest) {
postsCount: importPosts.length,
}).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
let importedPosts = 0;
for (const post of importPosts) {
+2 -2
View File
@@ -7,8 +7,8 @@ import { z } from 'zod';
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(50).optional(),
bio: z.string().max(160).optional().nullable(),
avatarUrl: z.string().url().optional().nullable(),
headerUrl: z.string().url().optional().nullable(),
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
headerUrl: 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 { v4 as uuidv4 } from 'uuid';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
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) {
try {
@@ -20,16 +22,20 @@ export async function POST(req: NextRequest) {
}
// 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({
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP'
error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV'
}, { status: 400 });
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
// Validate file size based on type
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
if (file.size > maxSize) {
return NextResponse.json({
error: 'File too large. Maximum size: 10MB'
error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}`
}, { status: 400 });
}
@@ -62,8 +68,10 @@ export async function POST(req: NextRequest) {
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else {
} else if (process.env.STORAGE_ENDPOINT) {
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
+3 -3
View File
@@ -39,10 +39,10 @@ export async function POST(req: NextRequest) {
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else {
// Fallback if no public URL configured (e.g. valid for some providers)
// This might need adjustment based on provider
} else if (process.env.STORAGE_ENDPOINT) {
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 });
+16
View File
@@ -347,6 +347,15 @@ a.btn-primary:visited {
display: block;
}
.post-media-item video {
width: 100%;
height: 100%;
max-height: 360px;
object-fit: cover;
display: block;
cursor: pointer;
}
.video-embed-container {
position: relative;
padding-bottom: 56.25%;
@@ -532,6 +541,13 @@ a.btn-primary:visited {
display: block;
}
.compose-media-item video {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.compose-media-remove {
position: absolute;
top: 6px;
+35 -1
View File
@@ -24,6 +24,7 @@ export default function LoginPage() {
const [importPassword, setImportPassword] = useState('');
const [importHandle, setImportHandle] = useState('');
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
const [importAgeVerified, setImportAgeVerified] = useState(false);
const [importSuccess, setImportSuccess] = useState<string | null>(null);
// Fetch node info
@@ -37,6 +38,10 @@ export default function LoginPage() {
logoUrl: data.logoUrl || undefined,
isNsfw: data.isNsfw || false
});
// Update page title
if (data.name && data.name !== 'Synapsis') {
document.title = data.name;
}
setNodeInfoLoaded(true);
})
.catch(() => {
@@ -77,6 +82,11 @@ export default function LoginPage() {
return;
}
if (nodeInfo.isNsfw && !importAgeVerified) {
setError('You must verify your age to import an account on this node');
return;
}
setLoading(true);
setError('');
setImportSuccess(null);
@@ -564,11 +574,35 @@ export default function LoginPage() {
</label>
</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
type="submit"
className="btn btn-primary btn-lg"
style={{ width: '100%' }}
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance}
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
>
{loading ? 'Importing...' : 'Import Account'}
</button>
+28 -16
View File
@@ -3,10 +3,14 @@
import { useState, useEffect } from 'react';
import AutoTextarea from '@/components/AutoTextarea';
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 { formatFullHandle } from '@/lib/utils/handle';
interface MediaAttachment extends Attachment {
mimeType?: string;
}
interface ComposeProps {
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void;
replyingTo?: Post | null;
@@ -18,7 +22,7 @@ interface ComposeProps {
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [linkPreview, setLinkPreview] = useState<any>(null);
@@ -97,7 +101,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
setUploadError(null);
setIsUploading(true);
const uploaded: Attachment[] = [];
const uploaded: MediaAttachment[] = [];
for (const file of selectedFiles) {
try {
@@ -117,6 +121,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
id: data.media.id,
url: data.media.url || data.url,
altText: data.media.altText ?? null,
mimeType: data.media.mimeType ?? file.type,
});
} catch (error) {
console.error('Upload failed', error);
@@ -153,18 +158,25 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
/>
{attachments.length > 0 && (
<div className="compose-media-grid">
{attachments.map((item) => (
<div className="compose-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Upload preview'} />
<button
type="button"
className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
))}
{attachments.map((item) => {
const isVideo = item.mimeType?.startsWith('video/');
return (
<div className="compose-media-item" key={item.id}>
{isVideo ? (
<video src={item.url} muted playsInline preload="metadata" />
) : (
<img src={item.url} alt={item.altText || 'Upload preview'} />
)}
<button
type="button"
className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
);
})}
</div>
)}
@@ -219,7 +231,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
{isUploading ? '...' : <ImageIcon size={20} />}
<input
type="file"
accept="image/*"
accept="image/*,video/mp4,video/webm,video/quicktime"
multiple
onChange={handleMediaSelect}
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 && (
<div className="post-media-grid">
{post.media.map((item) => (
<div className="post-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
</div>
))}
{post.media.map((item) => {
const isVideo = item.mimeType?.startsWith('video/');
return (
<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>
)}
+4
View File
@@ -43,6 +43,10 @@ export function AccentColorProvider({ children }: { children: ReactNode }) {
setAccentColor(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(() => {});
}, []);
+1
View File
@@ -26,6 +26,7 @@ export interface MediaItem {
id: string;
url: string;
altText?: string | null;
mimeType?: string | null;
}
export interface Attachment {