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:
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user