Add admin dashboard and Replit authentication integration
Integrates Replit authentication using OpenID Connect, adds an admin dashboard route with video management and thumbnail upload capabilities, and updates dependencies. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 890577b1-c154-40a4-a177-a0c6d55320c3 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/890577b1-c154-40a4-a177-a0c6d55320c3/1jMBtLj
This commit is contained in:
parent
12df511504
commit
c71720454f
1
.replit
1
.replit
@ -40,3 +40,4 @@ args = "npm run dev"
|
|||||||
waitForPort = 5000
|
waitForPort = 5000
|
||||||
|
|
||||||
[agent]
|
[agent]
|
||||||
|
integrations = ["javascript_database==1.0.0", "javascript_log_in_with_replit==1.0.0", "javascript_object_storage==1.0.0"]
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import VideoPage from "@/pages/VideoPage";
|
|||||||
import FolxStadlPage from "@/pages/FolxStadlPage";
|
import FolxStadlPage from "@/pages/FolxStadlPage";
|
||||||
import GeschichteLiedPage from "@/pages/GeschichteLiedPage";
|
import GeschichteLiedPage from "@/pages/GeschichteLiedPage";
|
||||||
import GipfelstammtischPage from "@/pages/GipfelstammtischPage";
|
import GipfelstammtischPage from "@/pages/GipfelstammtischPage";
|
||||||
|
import AdminPage from "@/pages/admin";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
@ -18,6 +19,7 @@ function Router() {
|
|||||||
<Route path="/folx-stadl" component={FolxStadlPage} />
|
<Route path="/folx-stadl" component={FolxStadlPage} />
|
||||||
<Route path="/geschichte-lied" component={GeschichteLiedPage} />
|
<Route path="/geschichte-lied" component={GeschichteLiedPage} />
|
||||||
<Route path="/gipfelstammtisch" component={GipfelstammtischPage} />
|
<Route path="/gipfelstammtisch" component={GipfelstammtischPage} />
|
||||||
|
<Route path="/admin" component={AdminPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ interface LoadingSpinnerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoadingSpinner({ size = 'md', text = 'Loading...', className = '' }: LoadingSpinnerProps) {
|
export function LoadingSpinner({ size = 'md', text = 'Loading...', className = '' }: LoadingSpinnerProps) {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'w-6 h-6',
|
sm: 'w-6 h-6',
|
||||||
md: 'w-10 h-10',
|
md: 'w-10 h-10',
|
||||||
@ -26,3 +26,5 @@ export default function LoadingSpinner({ size = 'md', text = 'Loading...', class
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
16
client/src/hooks/useAuth.ts
Normal file
16
client/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/auth/user"],
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.isAdmin || false,
|
||||||
|
isSuperAdmin: user?.isSuperAdmin || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
354
client/src/pages/admin.tsx
Normal file
354
client/src/pages/admin.tsx
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { LoadingSpinner } from "@/components/loading-spinner";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { Video } from "@shared/schema";
|
||||||
|
import { Shield, Edit, Upload, Search, Filter, Save, X } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { user, isLoading: authLoading, isAuthenticated, isAdmin } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Redirect if not admin
|
||||||
|
if (!authLoading && (!isAuthenticated || !isAdmin)) {
|
||||||
|
window.location.href = "/api/login";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#2D1B69] to-[#6366f1] flex items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#2D1B69] to-[#6366f1]">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Shield className="w-8 h-8 text-white" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
|
||||||
|
<p className="text-white/80">Manage go4.video content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Badge variant="secondary" className="text-sm">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.location.href = "/api/logout"}
|
||||||
|
className="text-white border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="bg-white/10 border-white/20 text-white">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
<span>Filters</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-white/90">Search Videos</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-3 w-4 h-4 text-white/60" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by title..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<VideoManagement search={search} onEditVideo={setSelectedVideo} onOpenDialog={setEditDialogOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Video Dialog */}
|
||||||
|
{selectedVideo && (
|
||||||
|
<EditVideoDialog
|
||||||
|
video={selectedVideo}
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSelectedVideo(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoManagement({
|
||||||
|
search,
|
||||||
|
onEditVideo,
|
||||||
|
onOpenDialog
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onEditVideo: (video: Video) => void;
|
||||||
|
onOpenDialog: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/admin/videos", { search, limit: 50 }],
|
||||||
|
queryFn: () => apiRequest(`/api/admin/videos?limit=50&search=${encodeURIComponent(search)}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/10 border-white/20">
|
||||||
|
<CardContent className="p-8 flex justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = data?.videos || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/10 border-white/20 text-white">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Video Management ({videos.length})</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{videos.map((video: Video) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
className="flex items-center space-x-4 p-4 bg-white/5 rounded-lg border border-white/10 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={video.customThumbnailUrl || video.thumbnailUrl}
|
||||||
|
alt={video.title}
|
||||||
|
className="w-24 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-white truncate">{video.title}</h3>
|
||||||
|
<p className="text-sm text-white/70 truncate">{video.description}</p>
|
||||||
|
<div className="flex items-center space-x-4 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs border-white/20 text-white/80">
|
||||||
|
{video.contentType}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs border-white/20 text-white/80">
|
||||||
|
{video.genre}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-white/60">{video.views} views</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onEditVideo(video);
|
||||||
|
onOpenDialog(true);
|
||||||
|
}}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditVideoDialog({
|
||||||
|
video,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: video.title,
|
||||||
|
description: video.description,
|
||||||
|
contentType: video.contentType,
|
||||||
|
genre: video.genre,
|
||||||
|
customThumbnailUrl: video.customThumbnailUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiRequest(`/api/admin/videos/${video.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Video updated successfully",
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Failed to update video",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateMutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl bg-[#2D1B69] border-white/20 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center space-x-2">
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
<span>Edit Video</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="text-white/90">Title</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
className="bg-white/10 border-white/20 text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="text-white/90">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="bg-white/10 border-white/20 text-white min-h-[100px]"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-white/90">Content Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.contentType}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, contentType: value as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#2D1B69] border-white/20">
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="oddaja">Oddaja</SelectItem>
|
||||||
|
<SelectItem value="music_video">Music Video</SelectItem>
|
||||||
|
<SelectItem value="documentary">Documentary</SelectItem>
|
||||||
|
<SelectItem value="live">Live</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-white/90">Genre</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.genre}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, genre: value as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#2D1B69] border-white/20">
|
||||||
|
<SelectItem value="volksmusik">Volksmusik</SelectItem>
|
||||||
|
<SelectItem value="schlager">Schlager</SelectItem>
|
||||||
|
<SelectItem value="pop">Pop</SelectItem>
|
||||||
|
<SelectItem value="rock">Rock</SelectItem>
|
||||||
|
<SelectItem value="country">Country</SelectItem>
|
||||||
|
<SelectItem value="instrumental">Instrumental</SelectItem>
|
||||||
|
<SelectItem value="dance">Dance</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="text-white/90">Custom Thumbnail URL</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.customThumbnailUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, customThumbnailUrl: e.target.value })}
|
||||||
|
className="bg-white/10 border-white/20 text-white"
|
||||||
|
placeholder="https://example.com/thumbnail.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="bg-gradient-to-r from-cyan-500 to-purple-600 hover:from-cyan-600 hover:to-purple-700 text-white"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
package-lock.json
generated
48
package-lock.json
generated
@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.16.0",
|
"@google-cloud/storage": "^7.17.0",
|
||||||
"@google-cloud/video-intelligence": "^6.2.0",
|
"@google-cloud/video-intelligence": "^6.2.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"@uppy/aws-s3": "^4.3.2",
|
"@uppy/aws-s3": "^4.3.2",
|
||||||
"@uppy/core": "^4.5.2",
|
"@uppy/core": "^4.5.3",
|
||||||
"@uppy/dashboard": "^4.4.3",
|
"@uppy/dashboard": "^4.4.3",
|
||||||
"@uppy/drag-drop": "^4.2.2",
|
"@uppy/drag-drop": "^4.2.2",
|
||||||
"@uppy/file-input": "^4.2.2",
|
"@uppy/file-input": "^4.2.2",
|
||||||
@ -70,7 +70,7 @@
|
|||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"face-api.js": "^0.22.2",
|
"face-api.js": "^0.22.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"google-auth-library": "^10.2.1",
|
"google-auth-library": "^10.3.0",
|
||||||
"hls.js": "^1.6.7",
|
"hls.js": "^1.6.7",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
@ -80,7 +80,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^2.0.0",
|
"node-fetch": "^2.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"openid-client": "^6.6.3",
|
"openid-client": "^6.7.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -1380,9 +1380,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@google-cloud/storage": {
|
"node_modules/@google-cloud/storage": {
|
||||||
"version": "7.16.0",
|
"version": "7.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
|
||||||
"integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==",
|
"integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/paginator": "^5.0.0",
|
"@google-cloud/paginator": "^5.0.0",
|
||||||
@ -4768,9 +4768,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@uppy/core": {
|
"node_modules/@uppy/core": {
|
||||||
"version": "4.5.2",
|
"version": "4.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-4.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-4.5.3.tgz",
|
||||||
"integrity": "sha512-5XoPJNcqEXtxtw+vg8EyqUFe11JSbG3/aln83Y7+CLbs7WOovYdfwwKEt1aTbfg1+ijsxudLchya5yh72jaLqw==",
|
"integrity": "sha512-52VLeBUY/j904h48lpPGykuWikkOOS4Lz/qkmalDiBQfNALb6iB1MOZs079IM3o/uMLYxzZRL80C3sKpkBUYcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@transloadit/prettier-bytes": "^0.3.4",
|
"@transloadit/prettier-bytes": "^0.3.4",
|
||||||
@ -7994,9 +7994,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/google-auth-library": {
|
"node_modules/google-auth-library": {
|
||||||
"version": "10.2.1",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.3.0.tgz",
|
||||||
"integrity": "sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==",
|
"integrity": "sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.3.0",
|
"base64-js": "^1.3.0",
|
||||||
@ -8450,9 +8450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.0.12",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
|
||||||
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
|
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@ -9407,9 +9407,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oauth4webapi": {
|
"node_modules/oauth4webapi": {
|
||||||
"version": "3.6.2",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.1.tgz",
|
||||||
"integrity": "sha512-hwWLiyBYuqhVdcIUJMJVKdEvz+DCweOcbSfqDyIv9PuUwrNfqrzfHP2bypZgZdbYOS67QYqnAnvZa2BJwBBrHw==",
|
"integrity": "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@ -9482,13 +9482,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "6.6.3",
|
"version": "6.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.7.1.tgz",
|
||||||
"integrity": "sha512-sYYFJsyN21bjf/QepIU/t6w22tEUT+rYVPf1VZOSQwC+s1hAkyZpvAbFNLMrnrYMS/H74MctEHna2jPLvWbkCA==",
|
"integrity": "sha512-kOiE4q0kNogr90hXsxPrKeEDuY+V0kkZazvZScOwZkYept9slsaQ3usXTaKkm6I04vLNuw5caBoX7UfrwC6x8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.1.0",
|
||||||
"oauth4webapi": "^3.6.1"
|
"oauth4webapi": "^3.8.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.16.0",
|
"@google-cloud/storage": "^7.17.0",
|
||||||
"@google-cloud/video-intelligence": "^6.2.0",
|
"@google-cloud/video-intelligence": "^6.2.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
@ -51,7 +51,7 @@
|
|||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"@uppy/aws-s3": "^4.3.2",
|
"@uppy/aws-s3": "^4.3.2",
|
||||||
"@uppy/core": "^4.5.2",
|
"@uppy/core": "^4.5.3",
|
||||||
"@uppy/dashboard": "^4.4.3",
|
"@uppy/dashboard": "^4.4.3",
|
||||||
"@uppy/drag-drop": "^4.2.2",
|
"@uppy/drag-drop": "^4.2.2",
|
||||||
"@uppy/file-input": "^4.2.2",
|
"@uppy/file-input": "^4.2.2",
|
||||||
@ -72,7 +72,7 @@
|
|||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"face-api.js": "^0.22.2",
|
"face-api.js": "^0.22.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"google-auth-library": "^10.2.1",
|
"google-auth-library": "^10.3.0",
|
||||||
"hls.js": "^1.6.7",
|
"hls.js": "^1.6.7",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
@ -82,7 +82,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^2.0.0",
|
"node-fetch": "^2.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"openid-client": "^6.6.3",
|
"openid-client": "^6.7.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
220
server/objectStorage.ts
Normal file
220
server/objectStorage.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { Storage, File } from "@google-cloud/storage";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106";
|
||||||
|
|
||||||
|
// Object storage client for Google Cloud Storage integration
|
||||||
|
export const objectStorageClient = new Storage({
|
||||||
|
credentials: {
|
||||||
|
audience: "replit",
|
||||||
|
subject_token_type: "access_token",
|
||||||
|
token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`,
|
||||||
|
type: "external_account",
|
||||||
|
credential_source: {
|
||||||
|
url: `${REPLIT_SIDECAR_ENDPOINT}/credential`,
|
||||||
|
format: {
|
||||||
|
type: "json",
|
||||||
|
subject_token_field_name: "access_token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
universe_domain: "googleapis.com",
|
||||||
|
},
|
||||||
|
projectId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ObjectNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Object not found");
|
||||||
|
this.name = "ObjectNotFoundError";
|
||||||
|
Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObjectStorageService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Get public object search paths
|
||||||
|
getPublicObjectSearchPaths(): Array<string> {
|
||||||
|
const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || "";
|
||||||
|
const paths = Array.from(
|
||||||
|
new Set(
|
||||||
|
pathsStr
|
||||||
|
.split(",")
|
||||||
|
.map((path) => path.trim())
|
||||||
|
.filter((path) => path.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (paths.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"PUBLIC_OBJECT_SEARCH_PATHS not set. Object storage not configured."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get private object directory
|
||||||
|
getPrivateObjectDir(): string {
|
||||||
|
const dir = process.env.PRIVATE_OBJECT_DIR || "";
|
||||||
|
if (!dir) {
|
||||||
|
throw new Error(
|
||||||
|
"PRIVATE_OBJECT_DIR not set. Object storage not configured."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get upload URL for thumbnail upload
|
||||||
|
async getThumbnailUploadURL(): Promise<string> {
|
||||||
|
const privateObjectDir = this.getPrivateObjectDir();
|
||||||
|
const objectId = randomUUID();
|
||||||
|
const fullPath = `${privateObjectDir}/thumbnails/${objectId}`;
|
||||||
|
|
||||||
|
const { bucketName, objectName } = parseObjectPath(fullPath);
|
||||||
|
|
||||||
|
return signObjectURL({
|
||||||
|
bucketName,
|
||||||
|
objectName,
|
||||||
|
method: "PUT",
|
||||||
|
ttlSec: 900, // 15 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download object to response
|
||||||
|
async downloadObject(file: File, res: Response, cacheTtlSec: number = 3600) {
|
||||||
|
try {
|
||||||
|
const [metadata] = await file.getMetadata();
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
"Content-Type": metadata.contentType || "application/octet-stream",
|
||||||
|
"Content-Length": metadata.size,
|
||||||
|
"Cache-Control": `public, max-age=${cacheTtlSec}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = file.createReadStream();
|
||||||
|
|
||||||
|
stream.on("error", (err) => {
|
||||||
|
console.error("Stream error:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Error streaming file" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.pipe(res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error downloading file:", error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Error downloading file" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get object file from path
|
||||||
|
async getObjectFile(objectPath: string): Promise<File> {
|
||||||
|
if (!objectPath.startsWith("/objects/")) {
|
||||||
|
throw new ObjectNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = objectPath.slice(1).split("/");
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new ObjectNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = parts.slice(1).join("/");
|
||||||
|
let entityDir = this.getPrivateObjectDir();
|
||||||
|
if (!entityDir.endsWith("/")) {
|
||||||
|
entityDir = `${entityDir}/`;
|
||||||
|
}
|
||||||
|
const objectEntityPath = `${entityDir}${entityId}`;
|
||||||
|
const { bucketName, objectName } = parseObjectPath(objectEntityPath);
|
||||||
|
const bucket = objectStorageClient.bucket(bucketName);
|
||||||
|
const objectFile = bucket.file(objectName);
|
||||||
|
const [exists] = await objectFile.exists();
|
||||||
|
if (!exists) {
|
||||||
|
throw new ObjectNotFoundError();
|
||||||
|
}
|
||||||
|
return objectFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize object path from storage URL to local path
|
||||||
|
normalizeObjectPath(rawPath: string): string {
|
||||||
|
if (!rawPath.startsWith("https://storage.googleapis.com/")) {
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(rawPath);
|
||||||
|
const rawObjectPath = url.pathname;
|
||||||
|
|
||||||
|
let objectEntityDir = this.getPrivateObjectDir();
|
||||||
|
if (!objectEntityDir.endsWith("/")) {
|
||||||
|
objectEntityDir = `${objectEntityDir}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawObjectPath.startsWith(objectEntityDir)) {
|
||||||
|
return rawObjectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = rawObjectPath.slice(objectEntityDir.length);
|
||||||
|
return `/objects/${entityId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse object path to bucket and object name
|
||||||
|
function parseObjectPath(path: string): {
|
||||||
|
bucketName: string;
|
||||||
|
objectName: string;
|
||||||
|
} {
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
path = `/${path}`;
|
||||||
|
}
|
||||||
|
const pathParts = path.split("/");
|
||||||
|
if (pathParts.length < 3) {
|
||||||
|
throw new Error("Invalid path: must contain at least a bucket name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketName = pathParts[1];
|
||||||
|
const objectName = pathParts.slice(2).join("/");
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucketName,
|
||||||
|
objectName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign object URL for upload/download
|
||||||
|
async function signObjectURL({
|
||||||
|
bucketName,
|
||||||
|
objectName,
|
||||||
|
method,
|
||||||
|
ttlSec,
|
||||||
|
}: {
|
||||||
|
bucketName: string;
|
||||||
|
objectName: string;
|
||||||
|
method: "GET" | "PUT" | "DELETE" | "HEAD";
|
||||||
|
ttlSec: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const request = {
|
||||||
|
bucket_name: bucketName,
|
||||||
|
object_name: objectName,
|
||||||
|
method,
|
||||||
|
expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
const response = await fetch(
|
||||||
|
`${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to sign object URL, errorcode: ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signed_url: signedURL } = await response.json();
|
||||||
|
return signedURL;
|
||||||
|
}
|
||||||
172
server/replitAuth.ts
Normal file
172
server/replitAuth.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import * as client from "openid-client";
|
||||||
|
import { Strategy, type VerifyFunction } from "openid-client/passport";
|
||||||
|
import passport from "passport";
|
||||||
|
import session from "express-session";
|
||||||
|
import type { Express, RequestHandler } from "express";
|
||||||
|
import memoize from "memoizee";
|
||||||
|
import connectPg from "connect-pg-simple";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
|
||||||
|
if (!process.env.REPLIT_DOMAINS) {
|
||||||
|
throw new Error("Environment variable REPLIT_DOMAINS not provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOidcConfig = memoize(
|
||||||
|
async () => {
|
||||||
|
return await client.discovery(
|
||||||
|
new URL(process.env.ISSUER_URL ?? "https://replit.com/oidc"),
|
||||||
|
process.env.REPL_ID!
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ maxAge: 3600 * 1000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getSession() {
|
||||||
|
const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week
|
||||||
|
const pgStore = connectPg(session);
|
||||||
|
const sessionStore = new pgStore({
|
||||||
|
conString: process.env.DATABASE_URL,
|
||||||
|
createTableIfMissing: false,
|
||||||
|
ttl: sessionTtl,
|
||||||
|
tableName: "sessions",
|
||||||
|
});
|
||||||
|
return session({
|
||||||
|
secret: process.env.SESSION_SECRET!,
|
||||||
|
store: sessionStore,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
maxAge: sessionTtl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserSession(
|
||||||
|
user: any,
|
||||||
|
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers
|
||||||
|
) {
|
||||||
|
user.claims = tokens.claims();
|
||||||
|
user.access_token = tokens.access_token;
|
||||||
|
user.refresh_token = tokens.refresh_token;
|
||||||
|
user.expires_at = user.claims?.exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertUser(claims: any) {
|
||||||
|
await storage.upsertUser({
|
||||||
|
id: claims["sub"],
|
||||||
|
email: claims["email"],
|
||||||
|
firstName: claims["first_name"],
|
||||||
|
lastName: claims["last_name"],
|
||||||
|
profileImageUrl: claims["profile_image_url"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupAuth(app: Express) {
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(getSession());
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
|
||||||
|
const verify: VerifyFunction = async (
|
||||||
|
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
|
||||||
|
verified: passport.AuthenticateCallback
|
||||||
|
) => {
|
||||||
|
const user = {};
|
||||||
|
updateUserSession(user, tokens);
|
||||||
|
await upsertUser(tokens.claims());
|
||||||
|
verified(null, user);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const domain of process.env.REPLIT_DOMAINS!.split(",")) {
|
||||||
|
const strategy = new Strategy(
|
||||||
|
{
|
||||||
|
name: `replitauth:${domain}`,
|
||||||
|
config,
|
||||||
|
scope: "openid email profile offline_access",
|
||||||
|
callbackURL: `https://${domain}/api/callback`,
|
||||||
|
},
|
||||||
|
verify,
|
||||||
|
);
|
||||||
|
passport.use(strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.serializeUser((user: Express.User, cb) => cb(null, user));
|
||||||
|
passport.deserializeUser((user: Express.User, cb) => cb(null, user));
|
||||||
|
|
||||||
|
app.get("/api/login", (req, res, next) => {
|
||||||
|
passport.authenticate(`replitauth:${req.hostname}`, {
|
||||||
|
prompt: "login consent",
|
||||||
|
scope: ["openid", "email", "profile", "offline_access"],
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/callback", (req, res, next) => {
|
||||||
|
passport.authenticate(`replitauth:${req.hostname}`, {
|
||||||
|
successReturnToOrRedirect: "/admin",
|
||||||
|
failureRedirect: "/api/login",
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/logout", (req, res) => {
|
||||||
|
req.logout(() => {
|
||||||
|
res.redirect(
|
||||||
|
client.buildEndSessionUrl(config, {
|
||||||
|
client_id: process.env.REPL_ID!,
|
||||||
|
post_logout_redirect_uri: `${req.protocol}://${req.hostname}`,
|
||||||
|
}).href
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthenticated: RequestHandler = async (req, res, next) => {
|
||||||
|
const user = req.user as any;
|
||||||
|
|
||||||
|
if (!req.isAuthenticated() || !user.expires_at) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (now <= user.expires_at) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = user.refresh_token;
|
||||||
|
if (!refreshToken) {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const tokenResponse = await client.refreshTokenGrant(config, refreshToken);
|
||||||
|
updateUserSession(user, tokenResponse);
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAdmin: RequestHandler = async (req, res, next) => {
|
||||||
|
const user = req.user as any;
|
||||||
|
|
||||||
|
if (!req.isAuthenticated() || !user.claims?.sub) {
|
||||||
|
return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbUser = await storage.getUser(user.claims.sub);
|
||||||
|
if (!dbUser || !dbUser.isAdmin) {
|
||||||
|
return res.status(403).json({ message: "Admin access required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking admin status:", error);
|
||||||
|
res.status(500).json({ message: "Internal server error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
109
server/routes.ts
109
server/routes.ts
@ -16,6 +16,8 @@ import fs from "fs";
|
|||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
|
import { setupAuth, isAuthenticated, isAdmin } from "./replitAuth";
|
||||||
|
import { ObjectStorageService, ObjectNotFoundError } from "./objectStorage";
|
||||||
|
|
||||||
// Extend express session
|
// Extend express session
|
||||||
declare module "express-session" {
|
declare module "express-session" {
|
||||||
@ -56,6 +58,9 @@ const authenticate = (req: Request, res: Response, next: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
|
// Setup Replit Auth first
|
||||||
|
await setupAuth(app);
|
||||||
|
|
||||||
// Add compression middleware for better performance
|
// Add compression middleware for better performance
|
||||||
app.use(compression({
|
app.use(compression({
|
||||||
level: 6,
|
level: 6,
|
||||||
@ -850,6 +855,110 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== ADMIN ROUTES =====
|
||||||
|
|
||||||
|
// Auth route to get current user
|
||||||
|
app.get('/api/auth/user', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.claims.sub;
|
||||||
|
const user = await storage.getUser(userId);
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch user" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin video management
|
||||||
|
app.get('/api/admin/videos', isAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
const offset = parseInt(req.query.offset as string) || 0;
|
||||||
|
const search = req.query.search as string;
|
||||||
|
|
||||||
|
const videos = await storage.getVideos(limit, offset, search);
|
||||||
|
const total = await storage.getVideoCount(search);
|
||||||
|
|
||||||
|
res.json({ videos, total, limit, offset });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin videos:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch videos" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/videos/:id', isAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const videoId = req.params.id;
|
||||||
|
const updateData = updateVideoSchema.parse(req.body);
|
||||||
|
|
||||||
|
const updatedVideo = await storage.updateVideo(videoId, updateData);
|
||||||
|
if (!updatedVideo) {
|
||||||
|
return res.status(404).json({ message: "Video not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(updatedVideo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating video:", error);
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ message: "Invalid video data", errors: error.errors });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: "Failed to update video" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thumbnail upload
|
||||||
|
app.post('/api/admin/thumbnails/upload', isAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const objectStorageService = new ObjectStorageService();
|
||||||
|
const uploadURL = await objectStorageService.getThumbnailUploadURL();
|
||||||
|
res.json({ uploadURL });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting thumbnail upload URL:", error);
|
||||||
|
res.status(500).json({ message: "Failed to get upload URL" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve uploaded objects
|
||||||
|
app.get("/objects/:objectPath(*)", async (req, res) => {
|
||||||
|
const objectStorageService = new ObjectStorageService();
|
||||||
|
try {
|
||||||
|
const objectFile = await objectStorageService.getObjectFile(req.path);
|
||||||
|
objectStorageService.downloadObject(objectFile, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error serving object:", error);
|
||||||
|
if (error instanceof ObjectNotFoundError) {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
return res.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User management (super admin only)
|
||||||
|
app.patch('/api/admin/users/:id/admin', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = req.user.claims.sub;
|
||||||
|
const currentUser = await storage.getUser(currentUserId);
|
||||||
|
|
||||||
|
if (!currentUser?.isSuperAdmin) {
|
||||||
|
return res.status(403).json({ message: "Super admin access required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { isAdmin } = req.body;
|
||||||
|
|
||||||
|
const updatedUser = await storage.updateUser(userId, { isAdmin });
|
||||||
|
if (!updatedUser) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(updatedUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating user admin status:", error);
|
||||||
|
res.status(500).json({ message: "Failed to update user" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export interface IStorage {
|
|||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getUserByUsername(username: string): Promise<User | undefined>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
createUser(user: InsertUser): Promise<User>;
|
||||||
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
|
||||||
|
upsertUser(user: any): Promise<User>;
|
||||||
validateUserPassword(email: string, password: string): Promise<User | null>;
|
validateUserPassword(email: string, password: string): Promise<User | null>;
|
||||||
|
|
||||||
// Upload operations
|
// Upload operations
|
||||||
@ -166,6 +167,34 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return result[0];
|
return result[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertUser(userData: any): Promise<User> {
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: userData.id,
|
||||||
|
email: userData.email,
|
||||||
|
firstName: userData.firstName,
|
||||||
|
lastName: userData.lastName,
|
||||||
|
profileImageUrl: userData.profileImageUrl,
|
||||||
|
username: userData.email || `user_${userData.id}`,
|
||||||
|
password: '', // No password for OAuth users
|
||||||
|
isAdmin: false,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: users.id,
|
||||||
|
set: {
|
||||||
|
email: userData.email,
|
||||||
|
firstName: userData.firstName,
|
||||||
|
lastName: userData.lastName,
|
||||||
|
profileImageUrl: userData.profileImageUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
||||||
const user = await this.getUserByEmail(email);
|
const user = await this.getUserByEmail(email);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@ -521,6 +550,25 @@ export class MemStorage implements IStorage {
|
|||||||
return updatedUser;
|
return updatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertUser(userData: any): Promise<User> {
|
||||||
|
const existingUser = this.users.get(userData.id);
|
||||||
|
const user: User = {
|
||||||
|
id: userData.id,
|
||||||
|
email: userData.email,
|
||||||
|
firstName: userData.firstName,
|
||||||
|
lastName: userData.lastName,
|
||||||
|
profileImageUrl: userData.profileImageUrl,
|
||||||
|
username: userData.email || `user_${userData.id}`,
|
||||||
|
password: '',
|
||||||
|
isAdmin: existingUser?.isAdmin || false,
|
||||||
|
isSuperAdmin: existingUser?.isSuperAdmin || false,
|
||||||
|
createdAt: existingUser?.createdAt || new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this.users.set(userData.id, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
||||||
const user = await this.getUserByEmail(email);
|
const user = await this.getUserByEmail(email);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@ -759,6 +807,10 @@ class BunnyStorage implements IStorage {
|
|||||||
throw new Error("User operations are not supported with Bunny.net integration");
|
throw new Error("User operations are not supported with Bunny.net integration");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertUser(userData: any): Promise<User> {
|
||||||
|
throw new Error("User operations are not supported with Bunny.net integration");
|
||||||
|
}
|
||||||
|
|
||||||
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
||||||
throw new Error("User operations are not supported with Bunny.net integration");
|
throw new Error("User operations are not supported with Bunny.net integration");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { pgTable, text, varchar, integer, timestamp, boolean, real } from "drizzle-orm/pg-core";
|
import { pgTable, text, varchar, integer, timestamp, boolean, real, pgEnum, jsonb, index } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Enums for better type safety
|
||||||
|
export const contentTypeEnum = pgEnum('content_type', ['video', 'oddaja', 'music_video', 'documentary', 'live']);
|
||||||
|
export const genreEnum = pgEnum('genre', ['volksmusik', 'schlager', 'pop', 'rock', 'country', 'instrumental', 'dance', 'other']);
|
||||||
|
|
||||||
export const videos = pgTable("videos", {
|
export const videos = pgTable("videos", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
@ -18,6 +22,8 @@ export const videos = pgTable("videos", {
|
|||||||
duration: integer("duration").notNull(), // in seconds
|
duration: integer("duration").notNull(), // in seconds
|
||||||
views: integer("views").notNull().default(0),
|
views: integer("views").notNull().default(0),
|
||||||
category: text("category").default("").notNull(),
|
category: text("category").default("").notNull(),
|
||||||
|
contentType: contentTypeEnum("content_type").default('video').notNull(),
|
||||||
|
genre: genreEnum("genre").default('other').notNull(),
|
||||||
tags: text("tags").array().default([]).notNull(),
|
tags: text("tags").array().default([]).notNull(),
|
||||||
isPublic: boolean("is_public").default(true).notNull(),
|
isPublic: boolean("is_public").default(true).notNull(),
|
||||||
uploadStatus: text("upload_status").default("pending").notNull(), // pending, processing, completed, failed
|
uploadStatus: text("upload_status").default("pending").notNull(), // pending, processing, completed, failed
|
||||||
@ -41,6 +47,7 @@ export const users = pgTable("users", {
|
|||||||
lastName: varchar("last_name", { length: 100 }),
|
lastName: varchar("last_name", { length: 100 }),
|
||||||
profileImageUrl: text("profile_image_url"),
|
profileImageUrl: text("profile_image_url"),
|
||||||
isAdmin: boolean("is_admin").default(false).notNull(),
|
isAdmin: boolean("is_admin").default(false).notNull(),
|
||||||
|
isSuperAdmin: boolean("is_super_admin").default(false).notNull(),
|
||||||
createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
@ -84,6 +91,15 @@ export const videoTags = pgTable("video_tags", {
|
|||||||
tagId: varchar("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
|
tagId: varchar("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session storage table for Replit Auth
|
||||||
|
export const sessions = pgTable("sessions", {
|
||||||
|
sid: varchar("sid").primaryKey(),
|
||||||
|
sess: jsonb("sess").notNull(),
|
||||||
|
expire: timestamp("expire").notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index("IDX_session_expire").on(table.expire)
|
||||||
|
]);
|
||||||
|
|
||||||
// Video ads/spots table for storing advertisement metadata
|
// Video ads/spots table for storing advertisement metadata
|
||||||
export const videoAds = pgTable("video_ads", {
|
export const videoAds = pgTable("video_ads", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user