From fd719b9cfda41462fee35a10e91bad561d7ae2a7 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:00:22 -0500 Subject: [PATCH 01/28] Initial work for ads on Modtale --- .../config/security/SecurityConfig.java | 1 + .../modtale/controller/ad/AdController.java | 30 ++++++ .../net/modtale/model/ad/AffiliateAd.java | 40 +++++++ .../repository/ad/AffiliateAdRepository.java | 12 +++ .../net/modtale/service/ad/AdService.java | 44 ++++++++ frontend/src/components/ads/AdUnit.tsx | 101 ++++++++++++++++++ frontend/src/react-pages/Home.tsx | 50 ++++++--- .../src/react-pages/resources/ModDetail.tsx | 3 + frontend/src/types.ts | 7 ++ 9 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/net/modtale/controller/ad/AdController.java create mode 100644 backend/src/main/java/net/modtale/model/ad/AffiliateAd.java create mode 100644 backend/src/main/java/net/modtale/repository/ad/AffiliateAdRepository.java create mode 100644 backend/src/main/java/net/modtale/service/ad/AdService.java create mode 100644 frontend/src/components/ads/AdUnit.tsx diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index ce0cbc1a..b24b63a8 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -150,6 +150,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ).permitAll() .requestMatchers("/sitemap.xml", "/actuator/health").permitAll() .requestMatchers("/client-metadata.json").permitAll() + .requestMatchers("/api/v1/ads/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() .requestMatchers( diff --git a/backend/src/main/java/net/modtale/controller/ad/AdController.java b/backend/src/main/java/net/modtale/controller/ad/AdController.java new file mode 100644 index 00000000..a7400f4e --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/ad/AdController.java @@ -0,0 +1,30 @@ +package net.modtale.controller.ad; + +import net.modtale.model.ad.AffiliateAd; +import net.modtale.service.ad.AdService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/ads") +public class AdController { + + @Autowired + private AdService adService; + + @GetMapping("/serve") + public ResponseEntity getAd() { + AffiliateAd ad = adService.getRandomAd(); + if (ad == null) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(ad); + } + + @PostMapping("/{id}/click") + public ResponseEntity trackClick(@PathVariable String id) { + adService.trackClick(id); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java b/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java new file mode 100644 index 00000000..2a08af94 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java @@ -0,0 +1,40 @@ +package net.modtale.model.ad; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "affiliate_ads") +public class AffiliateAd { + @Id + private String id; + private String title; + private String imageUrl; + private String linkUrl; + private boolean active; + private int views; + private int clicks; + + public AffiliateAd() {} + + public AffiliateAd(String title, String imageUrl, String linkUrl) { + this.title = title; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.active = true; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public String getLinkUrl() { return linkUrl; } + public void setLinkUrl(String linkUrl) { this.linkUrl = linkUrl; } + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + public int getViews() { return views; } + public void setViews(int views) { this.views = views; } + public int getClicks() { return clicks; } + public void setClicks(int clicks) { this.clicks = clicks; } +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/repository/ad/AffiliateAdRepository.java b/backend/src/main/java/net/modtale/repository/ad/AffiliateAdRepository.java new file mode 100644 index 00000000..1a16964f --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/ad/AffiliateAdRepository.java @@ -0,0 +1,12 @@ +package net.modtale.repository.ad; + +import net.modtale.model.ad.AffiliateAd; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; + +import java.util.List; + +public interface AffiliateAdRepository extends MongoRepository { + @Query("{ 'active': true }") + List findAllActive(); +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/service/ad/AdService.java b/backend/src/main/java/net/modtale/service/ad/AdService.java new file mode 100644 index 00000000..f611ff20 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/ad/AdService.java @@ -0,0 +1,44 @@ +package net.modtale.service.ad; + +import net.modtale.model.ad.AffiliateAd; +import net.modtale.repository.ad.AffiliateAdRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Random; + +@Service +public class AdService { + + @Autowired + private AffiliateAdRepository adRepository; + + private final Random random = new Random(); + + public AffiliateAd getRandomAd() { + if (random.nextDouble() > 0.5) { + return null; + } + + List ads = adRepository.findAllActive(); + if (ads.isEmpty()) { + return null; + } + AffiliateAd ad = ads.get(random.nextInt(ads.size())); + ad.setViews(ad.getViews() + 1); + adRepository.save(ad); + return ad; + } + + public void trackClick(String id) { + adRepository.findById(id).ifPresent(ad -> { + ad.setClicks(ad.getClicks() + 1); + adRepository.save(ad); + }); + } + + public AffiliateAd createAd(AffiliateAd ad) { + return adRepository.save(ad); + } +} \ No newline at end of file diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx new file mode 100644 index 00000000..5507a7a7 --- /dev/null +++ b/frontend/src/components/ads/AdUnit.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { api } from '../../utils/api'; +import type { AffiliateAd } from '../../types'; +import { ExternalLink } from 'lucide-react'; + +interface AdUnitProps { + className?: string; + variant: 'card' | 'sidebar'; +} + +export const AdUnit: React.FC = ({ className, variant }) => { + const [ad, setAd] = useState(null); + const [loading, setLoading] = useState(true); + const [useExternal, setUseExternal] = useState(false); + const adRef = useRef(null); + + useEffect(() => { + let mounted = true; + + const fetchAd = async () => { + try { + const res = await api.get('/ads/serve'); + if (mounted) { + if (res.status === 204) { + setUseExternal(true); + } else { + setAd(res.data); + } + setLoading(false); + } + } catch (error) { + if (mounted) { + setUseExternal(true); + setLoading(false); + } + } + }; + + fetchAd(); + return () => { mounted = false; }; + }, []); + + const handleClick = () => { + if (ad) { + api.post(`/ads/${ad.id}/click`).catch(() => {}); + } + }; + + if (loading) return null; + + if (useExternal) { + return ( +
+
+ Advertisement +
+
+ ); + } + + if (!ad) return null; + + if (variant === 'card') { + return ( + + {ad.title} +
+ + Sponsored + +

{ad.title}

+
+
+ ); + } + + return ( + +
+ {ad.title} +
Ad
+
+
+

{ad.title}

+

Visit Site

+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/react-pages/Home.tsx b/frontend/src/react-pages/Home.tsx index 01860715..a9d7662a 100644 --- a/frontend/src/react-pages/Home.tsx +++ b/frontend/src/react-pages/Home.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import React, {useState, useRef, useEffect, useCallback, useMemo, type JSX} from 'react'; import { Helmet } from 'react-helmet-async'; import { useNavigate } from 'react-router-dom'; import type { Mod, Modpack, World } from '../types'; @@ -14,6 +14,7 @@ import { getProjectUrl } from '../utils/slug'; import { EmptyState } from '../components/ui/EmptyState'; import { getCategorySEO } from '../data/seo-constants'; import { generateItemListSchema, generateBreadcrumbSchema, getBreadcrumbsForClassification } from '../utils/schema'; +import { AdUnit } from '../components/ads/AdUnit'; interface HomeProps { onModClick: (mod: Mod) => void; @@ -252,6 +253,36 @@ export const Home: React.FC = ({ const activeFilterCount = (selectedVersion !== 'Any' ? 1 : 0) + (minRating > 0 ? 1 : 0) + (minDownloads > 0 ? 1 : 0) + (filterDate ? 1 : 0); const seoContent = getCategorySEO(selectedClassification); + const renderItemsWithAds = () => { + const elements: JSX.Element[] = []; + items.forEach((item, index) => { + elements.push( +
+ handleToggleLocal(id, item.classification === 'MODPACK')} + isLoggedIn={isLoggedIn} + onClick={() => { if(item.classification === 'MODPACK') onModpackClick(item as Modpack); else if (item.classification === 'SAVE') onWorldClick(item as World); else onModClick(item as Mod); }} + /> +
+ ); + if (index === 5) { + elements.push( +
+ +
+ ); + } + }); + return elements; + }; + return (
@@ -338,22 +369,7 @@ export const Home: React.FC = ({
) : items.length > 0 ? (
- {items.map((item, index) => ( -
- handleToggleLocal(id, item.classification === 'MODPACK')} - isLoggedIn={isLoggedIn} - onClick={() => { if(item.classification === 'MODPACK') onModpackClick(item as Modpack); else if (item.classification === 'SAVE') onWorldClick(item as World); else onModClick(item as Mod); }} - /> -
- ))} + {renderItemsWithAds()}
) : (
diff --git a/frontend/src/react-pages/resources/ModDetail.tsx b/frontend/src/react-pages/resources/ModDetail.tsx index a462382e..7f5cbce5 100644 --- a/frontend/src/react-pages/resources/ModDetail.tsx +++ b/frontend/src/react-pages/resources/ModDetail.tsx @@ -27,6 +27,7 @@ import { ProjectLayout, SidebarSection } from '@/components/resources/ProjectLay import { generateProjectMeta } from '../../utils/meta'; import { getBreadcrumbsForClassification, generateBreadcrumbSchema } from '../../utils/schema'; import { ReportModal } from '@/components/resources/mod-detail/ReportModal'; +import { AdUnit } from '../../components/ads/AdUnit'; const DiscordIcon = ({ className }: { className?: string }) => ( @@ -140,6 +141,8 @@ const ProjectSidebar: React.FC<{
+ + {gameVersions.length > 0 && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 04a2c81c..d105c0ea 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,10 @@ +export interface AffiliateAd { + id: string; + title: string; + imageUrl: string; + linkUrl: string; +} + export interface ConnectedAccount { provider: string; providerId: string; From 923c0ae73381bf5d0aad71693864db6151c972a4 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:30:09 -0500 Subject: [PATCH 02/28] Allow the super admin to implement new affiliate ads --- .../java/net/modtale/config/DataSeeder.java | 45 ++- .../config/security/SecurityConfig.java | 5 +- .../modtale/controller/ad/AdController.java | 44 ++- .../net/modtale/service/ad/AdService.java | 69 +++- .../src/components/admin/AdManagement.tsx | 296 ++++++++++++++++++ frontend/src/react-pages/AdminPanel.tsx | 22 +- frontend/src/types.ts | 3 + 7 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/admin/AdManagement.tsx diff --git a/backend/src/main/java/net/modtale/config/DataSeeder.java b/backend/src/main/java/net/modtale/config/DataSeeder.java index f2c76b05..30611f59 100644 --- a/backend/src/main/java/net/modtale/config/DataSeeder.java +++ b/backend/src/main/java/net/modtale/config/DataSeeder.java @@ -3,6 +3,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.Filters; +import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import org.bson.Document; @@ -35,6 +36,7 @@ public class DataSeeder implements CommandLineRunner { private static final int PROJECT_LIMIT = 50; private static final int REPORT_LIMIT = 20; + private static final String SUPER_ADMIN_ID = "692620f7c2f3266e23ac0ded"; public DataSeeder(MongoTemplate mongoTemplate, UserRepository userRepository, PasswordEncoder passwordEncoder) { this.mongoTemplate = mongoTemplate; @@ -60,18 +62,21 @@ public void run(String... args) { return; } - logger.info("Initializing Preview Environment with relational subset from '{}'...", sourceDbName); + logger.info("Initializing Preview Environment..."); + + createSuperAdmin(); + createNormalUser(); try { + logger.info("Attempting to clone relational subset from '{}'...", sourceDbName); + MongoDatabase sourceDb = mongoTemplate.getMongoDatabaseFactory().getMongoDatabase(sourceDbName); MongoDatabase targetDb = mongoTemplate.getDb(); - createDevAdmin(); - List projects = fetchSubset(sourceDb, "projects", PROJECT_LIMIT); if (projects.isEmpty()) { - logger.info("No projects found in source. Seeding finished early."); + logger.info("No projects found in source. Seeding finished with just users."); return; } @@ -99,19 +104,36 @@ public void run(String... args) { logger.info("Seeding completed successfully."); } catch (Exception e) { - logger.error("Failed to seed preview database", e); + logger.error("Failed to seed preview database (Users were created, but cloning failed)", e); } } - private void createDevAdmin() { + private void createSuperAdmin() { + if (userRepository.existsById(SUPER_ADMIN_ID)) return; + User user = new User(); - user.setUsername("dev_admin"); + user.setId(SUPER_ADMIN_ID); // Explicitly set the secure ID + user.setUsername("super_admin"); user.setEmail("admin@modtale.net"); user.setPassword(passwordEncoder.encode("password")); user.setRoles(List.of("USER", "ADMIN")); - user.setBio("I am the generated admin for this preview environment."); + user.setBio("I am the Super Admin for this preview environment."); + user.setTier(ApiKey.Tier.ENTERPRISE); + userRepository.save(user); + logger.info("Created Super Admin: super_admin / password (ID: {})", SUPER_ADMIN_ID); + } + + private void createNormalUser() { + if (userRepository.findByUsername("user").isPresent()) return; + User user = new User(); + user.setUsername("user"); + user.setEmail("user@modtale.net"); + user.setPassword(passwordEncoder.encode("password")); + user.setRoles(List.of("USER")); + user.setBio("I am a standard user."); + user.setTier(ApiKey.Tier.USER); userRepository.save(user); - logger.info("Created Default Admin: dev_admin / password"); + logger.info("Created Normal User: user / password"); } private List fetchSubset(MongoDatabase db, String collectionName, int limit) { @@ -139,8 +161,11 @@ private void cloneSpecificUsers(MongoDatabase source, MongoDatabase target, Set< String defaultPasswordHash = passwordEncoder.encode("password"); for (Document user : usersToClone) { + String id = user.getObjectId("_id").toString(); + if (id.equals(SUPER_ADMIN_ID)) continue; + user.put("password", defaultPasswordHash); - user.put("email", "scrubbed_" + user.getObjectId("_id").toString() + "@modtale.local"); + user.put("email", "scrubbed_" + id + "@modtale.local"); user.put("githubAccessToken", null); user.put("gitlabAccessToken", null); } diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index b24b63a8..e51dc9fe 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -150,7 +150,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ).permitAll() .requestMatchers("/sitemap.xml", "/actuator/health").permitAll() .requestMatchers("/client-metadata.json").permitAll() - .requestMatchers("/api/v1/ads/**").permitAll() + .requestMatchers("/api/v1/ads/serve", "/api/v1/ads/*/click").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() .requestMatchers( @@ -158,7 +158,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/projects/*/analytics", "/api/v1/analytics/view/**", "/api/v1/user/api-keys/**", - "/api/v1/admin/**" + "/api/v1/admin/**", + "/api/v1/admin/ads/**" ).access((authentication, context) -> { boolean isApiKeyUser = authentication.get().getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_API")); diff --git a/backend/src/main/java/net/modtale/controller/ad/AdController.java b/backend/src/main/java/net/modtale/controller/ad/AdController.java index a7400f4e..4dc8b634 100644 --- a/backend/src/main/java/net/modtale/controller/ad/AdController.java +++ b/backend/src/main/java/net/modtale/controller/ad/AdController.java @@ -3,17 +3,23 @@ import net.modtale.model.ad.AffiliateAd; import net.modtale.service.ad.AdService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; @RestController -@RequestMapping("/api/v1/ads") +@RequestMapping("/api/v1") public class AdController { @Autowired private AdService adService; - @GetMapping("/serve") + @GetMapping("/ads/serve") public ResponseEntity getAd() { AffiliateAd ad = adService.getRandomAd(); if (ad == null) { @@ -22,9 +28,41 @@ public ResponseEntity getAd() { return ResponseEntity.ok(ad); } - @PostMapping("/{id}/click") + @PostMapping("/ads/{id}/click") public ResponseEntity trackClick(@PathVariable String id) { adService.trackClick(id); return ResponseEntity.ok().build(); } + + @GetMapping("/admin/ads") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllAds() { + return ResponseEntity.ok(adService.getAllAds()); + } + + @PostMapping(value = "/admin/ads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createAd( + @RequestPart("ad") AffiliateAd ad, + @RequestPart(value = "image", required = false) MultipartFile image + ) throws IOException { + return ResponseEntity.ok(adService.createAd(ad, image)); + } + + @PutMapping(value = "/admin/ads/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateAd( + @PathVariable String id, + @RequestPart("ad") AffiliateAd ad, + @RequestPart(value = "image", required = false) MultipartFile image + ) throws IOException { + return ResponseEntity.ok(adService.updateAd(id, ad, image)); + } + + @DeleteMapping("/admin/ads/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteAd(@PathVariable String id) { + adService.deleteAd(id); + return ResponseEntity.ok().build(); + } } \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/service/ad/AdService.java b/backend/src/main/java/net/modtale/service/ad/AdService.java index f611ff20..edf4fcfc 100644 --- a/backend/src/main/java/net/modtale/service/ad/AdService.java +++ b/backend/src/main/java/net/modtale/service/ad/AdService.java @@ -2,18 +2,28 @@ import net.modtale.model.ad.AffiliateAd; import net.modtale.repository.ad.AffiliateAdRepository; +import net.modtale.service.resources.StorageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; import java.util.Random; @Service public class AdService { + private static final Logger logger = LoggerFactory.getLogger(AdService.class); + @Autowired private AffiliateAdRepository adRepository; + @Autowired + private StorageService storageService; + private final Random random = new Random(); public AffiliateAd getRandomAd() { @@ -28,6 +38,12 @@ public AffiliateAd getRandomAd() { AffiliateAd ad = ads.get(random.nextInt(ads.size())); ad.setViews(ad.getViews() + 1); adRepository.save(ad); + + // Ensure we return the full public URL + if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { + ad.setImageUrl(storageService.getPublicUrl(ad.getImageUrl())); + } + return ad; } @@ -38,7 +54,58 @@ public void trackClick(String id) { }); } - public AffiliateAd createAd(AffiliateAd ad) { + public AffiliateAd createAd(AffiliateAd ad, MultipartFile image) throws IOException { + if (image != null && !image.isEmpty()) { + String path = storageService.upload(image, "ads"); + ad.setImageUrl(path); + } return adRepository.save(ad); } + + public List getAllAds() { + List ads = adRepository.findAll(); + // Resolve URLs for admin display + ads.forEach(ad -> { + if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { + ad.setImageUrl(storageService.getPublicUrl(ad.getImageUrl())); + } + }); + return ads; + } + + public AffiliateAd updateAd(String id, AffiliateAd updated, MultipartFile image) throws IOException { + return adRepository.findById(id).map(ad -> { + ad.setTitle(updated.getTitle()); + ad.setLinkUrl(updated.getLinkUrl()); + ad.setActive(updated.isActive()); + + if (image != null && !image.isEmpty()) { + // Delete old image if it exists and isn't external + if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { + storageService.deleteFile(ad.getImageUrl()); + } + try { + String path = storageService.upload(image, "ads"); + ad.setImageUrl(path); + } catch (IOException e) { + logger.error("Failed to upload ad image", e); + } + } else if (updated.getImageUrl() != null) { + if (!updated.getImageUrl().equals(storageService.getPublicUrl(ad.getImageUrl()))) { + ad.setImageUrl(updated.getImageUrl()); + } + } + + return adRepository.save(ad); + }).orElse(null); + } + + public void deleteAd(String id) { + adRepository.findById(id).ifPresent(ad -> { + if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { + storageService.deleteFile(ad.getImageUrl()); + } + adRepository.deleteById(id); + }); + } } \ No newline at end of file diff --git a/frontend/src/components/admin/AdManagement.tsx b/frontend/src/components/admin/AdManagement.tsx new file mode 100644 index 00000000..01c11722 --- /dev/null +++ b/frontend/src/components/admin/AdManagement.tsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { api } from '../../utils/api'; +import { Plus, Trash2, Edit2, ExternalLink, BarChart2, Check, X, Upload, Image as ImageIcon } from 'lucide-react'; +import type { AffiliateAd } from '../../types'; + +interface AdManagementProps { + setStatus: (status: any) => void; +} + +export const AdManagement: React.FC = ({ setStatus }) => { + const [ads, setAds] = useState([]); + const [loading, setLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [editingAd, setEditingAd] = useState(null); + + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(''); + + const [formData, setFormData] = useState({ + title: '', + linkUrl: '', + active: true, + imageUrl: '' + }); + + useEffect(() => { + fetchAds(); + }, []); + + useEffect(() => { + if (selectedFile) { + const url = URL.createObjectURL(selectedFile); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + } + }, [selectedFile]); + + const fetchAds = async () => { + setLoading(true); + try { + const res = await api.get('/admin/ads'); + setAds(res.data); + } catch (e) { + setStatus({ type: 'error', title: 'Error', msg: 'Failed to load ads.' }); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const data = new FormData(); + data.append('ad', new Blob([JSON.stringify({ + title: formData.title, + linkUrl: formData.linkUrl, + active: formData.active, + imageUrl: formData.imageUrl + })], { type: 'application/json' })); + + if (selectedFile) { + data.append('image', selectedFile); + } + + try { + if (editingAd) { + await api.put(`/admin/ads/${editingAd.id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + setStatus({ type: 'success', title: 'Updated', msg: 'Ad updated successfully.' }); + } else { + await api.post('/admin/ads', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + setStatus({ type: 'success', title: 'Created', msg: 'Ad created successfully.' }); + } + setShowModal(false); + resetForm(); + fetchAds(); + } catch (e: any) { + setStatus({ type: 'error', title: 'Error', msg: e.response?.data?.message || 'Failed to save ad.' }); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this ad?')) return; + try { + await api.delete(`/admin/ads/${id}`); + setStatus({ type: 'success', title: 'Deleted', msg: 'Ad deleted successfully.' }); + fetchAds(); + } catch (e) { + setStatus({ type: 'error', title: 'Error', msg: 'Failed to delete ad.' }); + } + }; + + const openEdit = (ad: AffiliateAd) => { + setEditingAd(ad); + setFormData({ + title: ad.title, + linkUrl: ad.linkUrl, + active: ad.active !== undefined ? ad.active : true, + imageUrl: ad.imageUrl + }); + setPreviewUrl(ad.imageUrl); + setSelectedFile(null); + setShowModal(true); + }; + + const openCreate = () => { + resetForm(); + setShowModal(true); + }; + + const resetForm = () => { + setEditingAd(null); + setFormData({ title: '', linkUrl: '', active: true, imageUrl: '' }); + setSelectedFile(null); + setPreviewUrl(''); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setSelectedFile(e.target.files[0]); + setFormData(prev => ({...prev, imageUrl: ''})); + } + }; + + return ( +
+
+
+

Affiliate Links

+

Manage internal affiliate advertisements.

+
+ +
+ + {loading ? ( +
Loading ads...
+ ) : ( +
+ {ads.map((ad) => ( +
+
+ {ad.imageUrl ? ( + {ad.title} + ) : ( +
+ +
+ )} +
+ {ad.active ? 'Active' : 'Inactive'} +
+
+
+

{ad.title}

+ + {ad.linkUrl} + + +
+
{(ad as any).views || 0} Views
+
{(ad as any).clicks || 0} Clicks
+
+ +
+ + +
+
+
+ ))} + {ads.length === 0 && ( +
+ No affiliate ads created yet. +
+ )} +
+ )} + + {showModal && ( +
+
+
+

{editingAd ? 'Edit Ad' : 'New Ad'}

+ +
+
+
+ + setFormData({ ...formData, title: e.target.value })} + className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" + placeholder="Ad Campaign Name" + /> +
+ +
+ +
fileInputRef.current?.click()} + className="relative w-full aspect-video rounded-xl bg-slate-100 dark:bg-black/20 border-2 border-dashed border-slate-300 dark:border-white/10 hover:border-modtale-accent hover:bg-slate-50 dark:hover:bg-white/5 transition-all cursor-pointer flex flex-col items-center justify-center group overflow-hidden" + > + {previewUrl ? ( + <> + Preview +
+ Change Image +
+ + ) : ( + <> + + Click to upload image + + )} +
+ + {(!previewUrl && formData.imageUrl) && ( +
+ Current external URL: {formData.imageUrl} +
+ )} +
+ +
+ + setFormData({ ...formData, linkUrl: e.target.value })} + className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" + placeholder="https://..." + /> +
+ +
+ + Active +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/react-pages/AdminPanel.tsx b/frontend/src/react-pages/AdminPanel.tsx index 79a1a03a..2f719c01 100644 --- a/frontend/src/react-pages/AdminPanel.tsx +++ b/frontend/src/react-pages/AdminPanel.tsx @@ -1,20 +1,21 @@ import React, { useState, useEffect } from 'react'; import { api } from '../utils/api.ts'; import { StatusModal } from '../components/ui/StatusModal.tsx'; -import { Shield, Users, LayoutDashboard, ShieldAlert, Package } from 'lucide-react'; +import { Shield, Users, LayoutDashboard, ShieldAlert, Package, Megaphone } from 'lucide-react'; import type { Mod } from '../types.ts'; import { VerificationQueue } from '../components/admin/VerificationQueue'; import { UserManagement } from '../components/admin/UserManagement'; import { ReviewInterface } from '../components/admin/ReviewInterface'; import { ReportQueue } from '../components/admin/ReportQueue'; import { ProjectManagement } from '../components/admin/ProjectManagement'; +import { AdManagement } from '../components/admin/AdManagement'; interface AdminPanelProps { currentUser: any; } export const AdminPanel: React.FC = ({ currentUser }) => { - const [activeTab, setActiveTab] = useState<'users' | 'verification' | 'reports' | 'projects'>('verification'); + const [activeTab, setActiveTab] = useState<'users' | 'verification' | 'reports' | 'projects' | 'ads'>('verification'); const [status, setStatus] = useState(null); const [pendingProjects, setPendingProjects] = useState([]); @@ -104,7 +105,7 @@ export const AdminPanel: React.FC = ({ currentUser }) => { ); } - const SidebarButton = ({ tab, icon: Icon, label, badge }: { tab: 'users' | 'verification' | 'reports' | 'projects', icon: any, label: string, badge?: number }) => ( + const SidebarButton = ({ tab, icon: Icon, label, badge }: { tab: 'users' | 'verification' | 'reports' | 'projects' | 'ads', icon: any, label: string, badge?: number }) => (
)} + + {activeTab === 'ads' && isSuperAdmin && ( +
+
+

Ad Management

+

Create and manage affiliate advertisements.

+
+ +
+ )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d105c0ea..17074fb2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3,6 +3,9 @@ export interface AffiliateAd { title: string; imageUrl: string; linkUrl: string; + active: boolean; + views?: number; + clicks?: number; } export interface ConnectedAccount { From afa7547b1be8c2c501927996cda6afc06901cdff Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:13:55 -0500 Subject: [PATCH 03/28] Try to fix auth on branch deployments --- .../modtale/config/security/SecurityConfig.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index e51dc9fe..80144ee3 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -90,12 +90,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti try { String host = URI.create(frontendUrl).getHost(); if (host != null && !host.equalsIgnoreCase("localhost")) { - String[] parts = host.split("\\."); - if (parts.length >= 2) { - String rootDomain = parts[parts.length - 2] + "." + parts[parts.length - 1]; - cookie.domain(rootDomain); - } else { - cookie.domain(host); + if (!host.endsWith(".run.app")) { + String[] parts = host.split("\\."); + if (parts.length >= 2) { + String rootDomain = parts[parts.length - 2] + "." + parts[parts.length - 1]; + cookie.domain(rootDomain); + } else { + cookie.domain(host); + } } } } catch (Exception e) { From 79f42c031e51c4890d02b70a42e0d187d47f403e Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:31:10 -0500 Subject: [PATCH 04/28] Try to fix CSRF issues on branch deployments --- .../config/security/SecurityConfig.java | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 80144ee3..6c6871aa 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -37,6 +37,8 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; @@ -78,30 +80,77 @@ public DaoAuthenticationProvider authenticationProvider() { return authProvider; } + private boolean isPreviewEnvironment() { + if (frontendUrl == null || frontendUrl.isBlank()) return false; + try { + String host = URI.create(frontendUrl).getHost(); + return host != null && host.endsWith(".run.app"); + } catch (Exception e) { + return false; + } + } + + @Bean + public CookieSerializer cookieSerializer() { + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setUseSecureCookie(true); + serializer.setCookiePath("/"); + + boolean isPreview = isPreviewEnvironment(); + + if (isPreview) { + serializer.setSameSite("None"); + logger.info("CookieSerializer: Detected Preview Environment. Using SameSite=None."); + } else { + serializer.setSameSite("Lax"); + logger.info("CookieSerializer: Detected Prod/Dev Environment. Using SameSite=Lax."); + + if (frontendUrl != null && !frontendUrl.isBlank()) { + try { + String host = URI.create(frontendUrl).getHost(); + if (host != null && !host.equalsIgnoreCase("localhost")) { + String[] parts = host.split("\\."); + if (parts.length >= 2) { + String rootDomain = parts[parts.length - 2] + "." + parts[parts.length - 1]; + serializer.setDomainName(rootDomain); + } + } + } catch (Exception e) { + logger.warn("Failed to parse frontend URL for cookie domain: {}", e.getMessage()); + } + } + } + return serializer; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { CookieCsrfTokenRepository tokenRepository = new CookieCsrfTokenRepository(); tokenRepository.setCookieHttpOnly(false); tokenRepository.setSecure(true); tokenRepository.setCookiePath("/"); + tokenRepository.setCookieCustomizer(cookie -> { - cookie.sameSite("None"); - if (frontendUrl != null && !frontendUrl.isBlank()) { - try { - String host = URI.create(frontendUrl).getHost(); - if (host != null && !host.equalsIgnoreCase("localhost")) { - if (!host.endsWith(".run.app")) { + boolean isPreview = isPreviewEnvironment(); + + if (isPreview) { + cookie.sameSite("None"); + } else { + cookie.sameSite("Lax"); + + if (frontendUrl != null && !frontendUrl.isBlank()) { + try { + String host = URI.create(frontendUrl).getHost(); + if (host != null && !host.equalsIgnoreCase("localhost")) { String[] parts = host.split("\\."); if (parts.length >= 2) { String rootDomain = parts[parts.length - 2] + "." + parts[parts.length - 1]; cookie.domain(rootDomain); - } else { - cookie.domain(host); } } + } catch (Exception e) { + logger.warn("Failed to set CSRF cookie domain: {}", e.getMessage()); } - } catch (Exception e) { - logger.warn("Failed to set CSRF cookie domain: {}", e.getMessage()); } } }); From 9d9648220cfa8cb46ab90346e8223f5545a52848 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:36:51 -0500 Subject: [PATCH 05/28] Remove duplicate bean --- .../config/security/auth/SessionConfig.java | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/auth/SessionConfig.java b/backend/src/main/java/net/modtale/config/security/auth/SessionConfig.java index 29c2e619..b26b5669 100644 --- a/backend/src/main/java/net/modtale/config/security/auth/SessionConfig.java +++ b/backend/src/main/java/net/modtale/config/security/auth/SessionConfig.java @@ -2,52 +2,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.data.mongo.config.annotation.web.http.EnableMongoHttpSession; -import org.springframework.session.web.http.CookieSerializer; -import org.springframework.session.web.http.DefaultCookieSerializer; - -import java.net.URI; @Configuration @EnableMongoHttpSession(maxInactiveIntervalInSeconds = 2592000, collectionName = "modtale_sessions") // 30 days public class SessionConfig { private static final Logger logger = LoggerFactory.getLogger(SessionConfig.class); - - @Value("${app.frontend.url}") - private String frontendUrl; - - @Bean - public CookieSerializer cookieSerializer() { - DefaultCookieSerializer serializer = new DefaultCookieSerializer(); - serializer.setCookieName("SESSION"); - serializer.setCookiePath("/"); - - serializer.setUseSecureCookie(true); - serializer.setSameSite("None"); - - if (frontendUrl != null && !frontendUrl.isBlank()) { - try { - String host = URI.create(frontendUrl).getHost(); - if (host != null && !host.equalsIgnoreCase("localhost")) { - String[] parts = host.split("\\."); - if (parts.length >= 2) { - String rootDomain = parts[parts.length - 2] + "." + parts[parts.length - 1]; - serializer.setDomainName(rootDomain); - } else { - serializer.setDomainName(host); - } - } - } catch (Exception e) { - serializer.setDomainNamePattern("(?i)^.+?\\.(\\w+\\.[a-z]+)$"); - } - } else { - serializer.setDomainNamePattern("(?i)^.+?\\.(\\w+\\.[a-z]+)$"); - } - - return serializer; - } } \ No newline at end of file From 239bcbd297979dfd5f61f3f3767ae2e22516846f Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:52:16 -0500 Subject: [PATCH 06/28] Fix CORS in preview environments --- .../modtale/config/security/SecurityConfig.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 6c6871aa..9682274a 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -132,12 +132,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti tokenRepository.setCookieCustomizer(cookie -> { boolean isPreview = isPreviewEnvironment(); - if (isPreview) { cookie.sameSite("None"); } else { cookie.sameSite("Lax"); - if (frontendUrl != null && !frontendUrl.isBlank()) { try { String host = URI.create(frontendUrl).getHost(); @@ -263,9 +261,17 @@ public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration restrictedConfig = new CorsConfiguration(); List restrictedOrigins = new ArrayList<>(); - if (frontendUrl != null && !frontendUrl.isBlank()) { - restrictedOrigins.add(frontendUrl); + + boolean isPreview = isPreviewEnvironment(); + + if (isPreview) { + restrictedOrigins.add("https://*.run.app"); + } else { + if (frontendUrl != null && !frontendUrl.isBlank()) { + restrictedOrigins.add(frontendUrl); + } } + restrictedConfig.setAllowedOriginPatterns(restrictedOrigins); restrictedConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); restrictedConfig.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type", "X-Xsrf-Token", "X-XSRF-TOKEN")); @@ -289,6 +295,9 @@ public CorsConfigurationSource corsConfigurationSource() { if (frontendUrl != null && !frontendUrl.isBlank()) { publicOrigins.add(frontendUrl); } + if (isPreview) { + publicOrigins.add("https://*.run.app"); + } publicConfig.setAllowedOriginPatterns(publicOrigins); publicConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); publicConfig.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type", "X-Xsrf-Token", "X-XSRF-TOKEN", "X-Modtale-Key")); From 0267262a4314f20b68054953ec03f57a76f422b4 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:56:32 -0500 Subject: [PATCH 07/28] PreAuthorize might be causing issues --- .../modtale/controller/AdminController.java | 4 +- .../modtale/controller/ad/AdController.java | 43 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/net/modtale/controller/AdminController.java b/backend/src/main/java/net/modtale/controller/AdminController.java index a54cb1b2..0ee3cc3d 100644 --- a/backend/src/main/java/net/modtale/controller/AdminController.java +++ b/backend/src/main/java/net/modtale/controller/AdminController.java @@ -48,7 +48,9 @@ private boolean isSuperAdmin(User user) { } private boolean isAdmin(User user) { - return (user != null && user.getRoles() != null && user.getRoles().contains("ADMIN")) || isSuperAdmin(user); + if (user == null) return false; + if (SUPER_ADMIN_ID.equals(user.getId())) return true; + return user.getRoles() != null && user.getRoles().contains("ADMIN"); } private User getSafeUser() { diff --git a/backend/src/main/java/net/modtale/controller/ad/AdController.java b/backend/src/main/java/net/modtale/controller/ad/AdController.java index 4dc8b634..2e5c54ae 100644 --- a/backend/src/main/java/net/modtale/controller/ad/AdController.java +++ b/backend/src/main/java/net/modtale/controller/ad/AdController.java @@ -1,11 +1,13 @@ package net.modtale.controller.ad; import net.modtale.model.ad.AffiliateAd; +import net.modtale.model.user.User; import net.modtale.service.ad.AdService; +import net.modtale.service.user.UserService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -19,6 +21,29 @@ public class AdController { @Autowired private AdService adService; + @Autowired + private UserService userService; + + private static final String SUPER_ADMIN_ID = "692620f7c2f3266e23ac0ded"; + + private boolean isSuperAdmin(User user) { + return user != null && SUPER_ADMIN_ID.equals(user.getId()); + } + + private boolean isAdmin(User user) { + if (user == null) return false; + if (SUPER_ADMIN_ID.equals(user.getId())) return true; + return user.getRoles() != null && user.getRoles().contains("ADMIN"); + } + + private User getSafeUser() { + try { + return userService.getCurrentUser(); + } catch (Exception e) { + return null; + } + } + @GetMapping("/ads/serve") public ResponseEntity getAd() { AffiliateAd ad = adService.getRandomAd(); @@ -35,33 +60,41 @@ public ResponseEntity trackClick(@PathVariable String id) { } @GetMapping("/admin/ads") - @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> getAllAds() { + User currentUser = getSafeUser(); + if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(adService.getAllAds()); } @PostMapping(value = "/admin/ads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createAd( @RequestPart("ad") AffiliateAd ad, @RequestPart(value = "image", required = false) MultipartFile image ) throws IOException { + User currentUser = getSafeUser(); + if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(adService.createAd(ad, image)); } @PutMapping(value = "/admin/ads/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasRole('ADMIN')") public ResponseEntity updateAd( @PathVariable String id, @RequestPart("ad") AffiliateAd ad, @RequestPart(value = "image", required = false) MultipartFile image ) throws IOException { + User currentUser = getSafeUser(); + if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(adService.updateAd(id, ad, image)); } @DeleteMapping("/admin/ads/{id}") - @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteAd(@PathVariable String id) { + User currentUser = getSafeUser(); + if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + adService.deleteAd(id); return ResponseEntity.ok().build(); } From 85968baee84ceadd1ad56a0b44e2295b71b5cdb4 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:21:53 -0500 Subject: [PATCH 08/28] Attempt to fix more CSRF issues --- .../main/java/net/modtale/config/security/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 9682274a..3fa3e109 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -134,6 +134,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti boolean isPreview = isPreviewEnvironment(); if (isPreview) { cookie.sameSite("None"); + cookie.domain(null); } else { cookie.sameSite("Lax"); if (frontendUrl != null && !frontendUrl.isBlank()) { From f3de6ae89e4fe664b12490232ed59bb65b449394 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:35:59 -0500 Subject: [PATCH 09/28] Attempt to fix even more CSRF issues --- .../config/security/SecurityConfig.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 3fa3e109..8c7fa61f 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -130,8 +130,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti tokenRepository.setSecure(true); tokenRepository.setCookiePath("/"); + // Configure Cookie settings (SameSite) based on environment tokenRepository.setCookieCustomizer(cookie -> { boolean isPreview = isPreviewEnvironment(); + if (isPreview) { cookie.sameSite("None"); cookie.domain(null); @@ -160,12 +162,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .authenticationProvider(authenticationProvider()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(csrf -> csrf - .csrfTokenRepository(tokenRepository) - .csrfTokenRequestHandler(requestHandler) - .ignoringRequestMatchers("/api/v1/user/api-keys/**", "/api/v1/auth/**") - .ignoringRequestMatchers(request -> request.getHeader("X-MODTALE-KEY") != null) - ) + .csrf(csrf -> { + csrf + .csrfTokenRepository(tokenRepository) + .csrfTokenRequestHandler(requestHandler); + + csrf.ignoringRequestMatchers("/api/v1/user/api-keys/**", "/api/v1/auth/**"); + csrf.ignoringRequestMatchers(request -> request.getHeader("X-MODTALE-KEY") != null); + + if (isPreviewEnvironment()) { + logger.warn("SECURITY WARNING: Disabling CSRF protection for Staging/Preview environment to allow cross-site requests."); + csrf.ignoringRequestMatchers("/**"); + } + }) .addFilterBefore(rateLimitFilter, OAuth2LoginAuthenticationFilter.class) .addFilterBefore(apiKeyAuthFilter, OAuth2LoginAuthenticationFilter.class) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) From ec83fdd66197aac63ffc8532c18e3e48213378c6 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:55:17 -0800 Subject: [PATCH 10/28] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b05fed71..d272d49c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -84,7 +84,7 @@ jobs: "--update-env-vars" "APP_SEEDING_SOURCE_DB=modtale" "--update-env-vars" "APP_WARDEN_URL=${{ secrets.WARDEN_URL }}" "--update-env-vars" "FRONTEND_URL=placeholder" - "--update-env-vars" "R2_BUCKET_NAME=${{ env.R2_BUCKET_NAME }}" + "--update-env-vars" "R2_BUCKET_NAME=${{ env.R2_BUCKET_NAME }}" "--update-env-vars" "R2_PUBLIC_DOMAIN=${{ env.R2_PUBLIC_DOMAIN }}" ) From ce5ce057a0d1b9bcab0488656cbab291a6fe2487 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:55:36 -0500 Subject: [PATCH 11/28] Handle buckets better --- .../config/security/SecurityConfig.java | 1 - .../service/resources/StorageService.java | 32 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 8c7fa61f..c9dbf2f9 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -130,7 +130,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti tokenRepository.setSecure(true); tokenRepository.setCookiePath("/"); - // Configure Cookie settings (SameSite) based on environment tokenRepository.setCookieCustomizer(cookie -> { boolean isPreview = isPreviewEnvironment(); diff --git a/backend/src/main/java/net/modtale/service/resources/StorageService.java b/backend/src/main/java/net/modtale/service/resources/StorageService.java index 8623d937..01c590c0 100644 --- a/backend/src/main/java/net/modtale/service/resources/StorageService.java +++ b/backend/src/main/java/net/modtale/service/resources/StorageService.java @@ -47,6 +47,10 @@ public class StorageService { } public String upload(MultipartFile file, String pathPrefix) throws IOException { + if (bucketName == null || bucketName.isEmpty()) { + throw new IOException("Storage configuration error: Bucket name is not set."); + } + String originalName = file.getOriginalFilename(); if (originalName == null) originalName = "unknown"; @@ -58,14 +62,20 @@ public String upload(MultipartFile file, String pathPrefix) throws IOException { String contentDisposition = "attachment; filename=\"" + originalName.replace("\"", "") + "\""; - PutObjectRequest putOb = PutObjectRequest.builder() - .bucket(bucketName) - .key(storageKey) - .contentType(safeContentType) - .contentDisposition(contentDisposition) - .build(); + try { + PutObjectRequest putOb = PutObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .contentType(safeContentType) + .contentDisposition(contentDisposition) + .build(); - s3Client.putObject(putOb, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + s3Client.putObject(putOb, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + logger.info("Successfully uploaded {} to bucket {}", storageKey, bucketName); + } catch (S3Exception e) { + logger.error("Failed to upload to S3/R2. Bucket: {}, Key: {}. Error: {}", bucketName, storageKey, e.getMessage()); + throw new IOException("Cloud storage error: " + e.getMessage(), e); + } return storageKey; } @@ -123,9 +133,9 @@ public void deleteFile(String fileName) { .key(fileName) .build(); s3Client.deleteObject(deleteReq); - logger.info("R2: Deleted file " + fileName); + logger.info("R2: Deleted file " + fileName + " from bucket " + bucketName); } catch (Exception e) { - logger.error("R2 ERROR: Failed to delete " + fileName, e); + logger.error("R2 ERROR: Failed to delete " + fileName + " from bucket " + bucketName, e); } } @@ -138,7 +148,7 @@ public byte[] download(String fileName) throws IOException { ResponseInputStream response = s3Client.getObject(getReq); return response.readAllBytes(); } catch (NoSuchKeyException e) { - throw new IOException("File not found: " + fileName); + throw new IOException("File not found in bucket " + bucketName + ": " + fileName); } } @@ -150,7 +160,7 @@ public InputStream getStream(String fileName) throws IOException { .build(); return s3Client.getObject(getReq); } catch (NoSuchKeyException e) { - throw new IOException("File not found: " + fileName); + throw new IOException("File not found in bucket " + bucketName + ": " + fileName); } } From 9145f59c695c25f445c62e5e86c2fe9739bec9cd Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:02:21 -0500 Subject: [PATCH 12/28] Attempt to fix R2 issues --- .../main/java/net/modtale/config/r2/R2Config.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/net/modtale/config/r2/R2Config.java b/backend/src/main/java/net/modtale/config/r2/R2Config.java index 99c69e53..1c6f31ce 100644 --- a/backend/src/main/java/net/modtale/config/r2/R2Config.java +++ b/backend/src/main/java/net/modtale/config/r2/R2Config.java @@ -1,5 +1,7 @@ package net.modtale.config.r2; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,6 +16,8 @@ @Configuration public class R2Config { + private static final Logger logger = LoggerFactory.getLogger(R2Config.class); + @Value("${app.r2.access-key}") private String accessKey; @@ -25,8 +29,15 @@ public class R2Config { @Bean public S3Client s3Client() { + URI uri = URI.create(endpoint); + String cleanEndpoint = uri.getScheme() + "://" + uri.getAuthority(); + + if (!endpoint.equals(cleanEndpoint)) { + logger.warn("Corrected R2 Endpoint from '{}' to '{}' to prevent path duplication.", endpoint, cleanEndpoint); + } + return S3Client.builder() - .endpointOverride(URI.create(endpoint)) + .endpointOverride(URI.create(cleanEndpoint)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create(accessKey, secretKey) From 55cb9be36d875d115ccc47ca9d8dd1568ed3ffa8 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 03:17:39 -0500 Subject: [PATCH 13/28] Attempt to fix image files not showing up on staging --- frontend/src/components/admin/AdManagement.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/admin/AdManagement.tsx b/frontend/src/components/admin/AdManagement.tsx index 01c11722..7bf34773 100644 --- a/frontend/src/components/admin/AdManagement.tsx +++ b/frontend/src/components/admin/AdManagement.tsx @@ -117,11 +117,13 @@ export const AdManagement: React.FC = ({ setStatus }) => { setFormData({ title: '', linkUrl: '', active: true, imageUrl: '' }); setSelectedFile(null); setPreviewUrl(''); + if (fileInputRef.current) fileInputRef.current.value = ''; // Reset input value }; const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setSelectedFile(e.target.files[0]); + if (e.target.files && e.target.files.length > 0) { + const file = e.target.files[0]; + setSelectedFile(file); setFormData(prev => ({...prev, imageUrl: ''})); } }; From 9f017652127983599982fbd60037119ef359b0ab Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:49:06 -0800 Subject: [PATCH 14/28] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index d272d49c..a8677d95 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -59,7 +59,7 @@ jobs: echo "SEEDING_ENABLED=true" >> $GITHUB_ENV echo "TAG=$SLUG" >> $GITHUB_ENV echo "R2_BUCKET_NAME=modtale-dev" >> $GITHUB_ENV - echo "R2_PUBLIC_DOMAIN=https://dev-cdn.modtale.net" >> $GITHUB_ENV + echo "R2_PUBLIC_DOMAIN=https://pub-dc8241faa0a343c080802bf00bc72bc5.r2.dev" >> $GITHUB_ENV fi - id: 'auth' From 2e85a8c80d6f6413243304c12123bb52e05ef349 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 04:00:38 -0500 Subject: [PATCH 15/28] Allow vertical ads --- frontend/src/components/ads/AdUnit.tsx | 10 +++++----- frontend/src/react-pages/Home.tsx | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index 5507a7a7..a6a35bd6 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -50,7 +50,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (useExternal) { return ( -
+
Advertisement
@@ -86,13 +86,13 @@ export const AdUnit: React.FC = ({ className, variant }) => { target="_blank" rel="nofollow noreferrer" onClick={handleClick} - className={`block rounded-xl overflow-hidden group relative border border-slate-200 dark:border-white/5 ${className}`} + className={`block rounded-xl overflow-hidden group relative border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 hover:shadow-md transition-all ${className}`} > -
- {ad.title} +
+ {ad.title}
Ad
-
+

{ad.title}

Visit Site

diff --git a/frontend/src/react-pages/Home.tsx b/frontend/src/react-pages/Home.tsx index a9d7662a..a484f1de 100644 --- a/frontend/src/react-pages/Home.tsx +++ b/frontend/src/react-pages/Home.tsx @@ -323,6 +323,10 @@ export const Home: React.FC = ({ ))}
+ +
+ +
From 159b0152afb6cfc39c528a1240a3886de3006712 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:05:51 -0500 Subject: [PATCH 16/28] Allow there to be multiple images per ad --- .../modtale/controller/ad/AdController.java | 17 +- .../java/net/modtale/model/ad/AdCreative.java | 45 +++ .../net/modtale/model/ad/AffiliateAd.java | 17 +- .../net/modtale/service/ad/AdService.java | 145 ++++++-- .../src/components/admin/AdManagement.tsx | 314 +++++++++--------- frontend/src/components/ads/AdUnit.tsx | 60 ++-- frontend/src/react-pages/Home.tsx | 4 + frontend/src/types.ts | 11 +- 8 files changed, 370 insertions(+), 243 deletions(-) create mode 100644 backend/src/main/java/net/modtale/model/ad/AdCreative.java diff --git a/backend/src/main/java/net/modtale/controller/ad/AdController.java b/backend/src/main/java/net/modtale/controller/ad/AdController.java index 2e5c54ae..1fa73c71 100644 --- a/backend/src/main/java/net/modtale/controller/ad/AdController.java +++ b/backend/src/main/java/net/modtale/controller/ad/AdController.java @@ -26,10 +26,6 @@ public class AdController { private static final String SUPER_ADMIN_ID = "692620f7c2f3266e23ac0ded"; - private boolean isSuperAdmin(User user) { - return user != null && SUPER_ADMIN_ID.equals(user.getId()); - } - private boolean isAdmin(User user) { if (user == null) return false; if (SUPER_ADMIN_ID.equals(user.getId())) return true; @@ -45,8 +41,8 @@ private User getSafeUser() { } @GetMapping("/ads/serve") - public ResponseEntity getAd() { - AffiliateAd ad = adService.getRandomAd(); + public ResponseEntity getAd(@RequestParam(required = false, defaultValue = "card") String placement) { + AffiliateAd ad = adService.getRandomAd(placement); if (ad == null) { return ResponseEntity.noContent().build(); } @@ -70,24 +66,25 @@ public ResponseEntity> getAllAds() { @PostMapping(value = "/admin/ads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createAd( @RequestPart("ad") AffiliateAd ad, - @RequestPart(value = "image", required = false) MultipartFile image + @RequestPart(value = "images", required = false) List images ) throws IOException { User currentUser = getSafeUser(); if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - return ResponseEntity.ok(adService.createAd(ad, image)); + return ResponseEntity.ok(adService.createAd(ad, images)); } @PutMapping(value = "/admin/ads/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateAd( @PathVariable String id, @RequestPart("ad") AffiliateAd ad, - @RequestPart(value = "image", required = false) MultipartFile image + @RequestPart(value = "images", required = false) List images, + @RequestParam(value = "deleteIds", required = false) List deleteIds ) throws IOException { User currentUser = getSafeUser(); if (!isAdmin(currentUser)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - return ResponseEntity.ok(adService.updateAd(id, ad, image)); + return ResponseEntity.ok(adService.updateAd(id, ad, images, deleteIds)); } @DeleteMapping("/admin/ads/{id}") diff --git a/backend/src/main/java/net/modtale/model/ad/AdCreative.java b/backend/src/main/java/net/modtale/model/ad/AdCreative.java new file mode 100644 index 00000000..7b5ae594 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/ad/AdCreative.java @@ -0,0 +1,45 @@ +package net.modtale.model.ad; + +import java.util.UUID; + +public class AdCreative { + private String id; + private String imageUrl; + private int width; + private int height; + private CreativeType type; + + public enum CreativeType { + BANNER, + SIDEBAR, + CARD + } + + public AdCreative() {} + + public AdCreative(String imageUrl, int width, int height) { + this.id = UUID.randomUUID().toString(); + this.imageUrl = imageUrl; + this.width = width; + this.height = height; + this.type = calculateType(width, height); + } + + public static CreativeType calculateType(int w, int h) { + double ratio = (double) w / h; + if (ratio >= 2.5) return CreativeType.BANNER; + if (ratio <= 0.8) return CreativeType.SIDEBAR; + return CreativeType.CARD; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public int getWidth() { return width; } + public void setWidth(int width) { this.width = width; } + public int getHeight() { return height; } + public void setHeight(int height) { this.height = height; } + public CreativeType getType() { return type; } + public void setType(CreativeType type) { this.type = type; } +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java b/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java index 2a08af94..0926b44f 100644 --- a/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java +++ b/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java @@ -2,23 +2,26 @@ import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; +import java.util.ArrayList; +import java.util.List; @Document(collection = "affiliate_ads") public class AffiliateAd { @Id private String id; private String title; - private String imageUrl; private String linkUrl; private boolean active; + + private List creatives = new ArrayList<>(); + private int views; private int clicks; public AffiliateAd() {} - public AffiliateAd(String title, String imageUrl, String linkUrl) { + public AffiliateAd(String title, String linkUrl) { this.title = title; - this.imageUrl = imageUrl; this.linkUrl = linkUrl; this.active = true; } @@ -27,14 +30,18 @@ public AffiliateAd(String title, String imageUrl, String linkUrl) { public void setId(String id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } - public String getImageUrl() { return imageUrl; } - public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } public String getLinkUrl() { return linkUrl; } public void setLinkUrl(String linkUrl) { this.linkUrl = linkUrl; } public boolean isActive() { return active; } public void setActive(boolean active) { this.active = active; } + public List getCreatives() { return creatives; } + public void setCreatives(List creatives) { this.creatives = creatives; } public int getViews() { return views; } public void setViews(int views) { this.views = views; } public int getClicks() { return clicks; } public void setClicks(int clicks) { this.clicks = clicks; } + + public String getFirstImage() { + return creatives.isEmpty() ? null : creatives.get(0).getImageUrl(); + } } \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/service/ad/AdService.java b/backend/src/main/java/net/modtale/service/ad/AdService.java index edf4fcfc..67da9fe1 100644 --- a/backend/src/main/java/net/modtale/service/ad/AdService.java +++ b/backend/src/main/java/net/modtale/service/ad/AdService.java @@ -1,5 +1,6 @@ package net.modtale.service.ad; +import net.modtale.model.ad.AdCreative; import net.modtale.model.ad.AffiliateAd; import net.modtale.repository.ad.AffiliateAdRepository; import net.modtale.service.resources.StorageService; @@ -9,9 +10,13 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Random; +import java.util.stream.Collectors; @Service public class AdService { @@ -26,25 +31,65 @@ public class AdService { private final Random random = new Random(); - public AffiliateAd getRandomAd() { + public AffiliateAd getRandomAd(String placement) { if (random.nextDouble() > 0.5) { return null; } - List ads = adRepository.findAllActive(); - if (ads.isEmpty()) { - return null; + AdCreative.CreativeType targetType = parsePlacement(placement); + + List allActive = adRepository.findAllActive(); + + List candidates = allActive.stream() + .filter(ad -> ad.getCreatives().stream().anyMatch(c -> c.getType() == targetType)) + .collect(Collectors.toList()); + + if (candidates.isEmpty()) { + if (targetType == AdCreative.CreativeType.SIDEBAR) { + candidates = allActive.stream() + .filter(ad -> ad.getCreatives().stream().anyMatch(c -> c.getType() == AdCreative.CreativeType.CARD)) + .collect(Collectors.toList()); + } + + if (candidates.isEmpty()) return null; } - AffiliateAd ad = ads.get(random.nextInt(ads.size())); - ad.setViews(ad.getViews() + 1); - adRepository.save(ad); - // Ensure we return the full public URL - if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { - ad.setImageUrl(storageService.getPublicUrl(ad.getImageUrl())); + AffiliateAd selectedAd = candidates.get(random.nextInt(candidates.size())); + + AdCreative bestCreative = selectedAd.getCreatives().stream() + .filter(c -> c.getType() == targetType) + .findFirst() + .orElse(selectedAd.getCreatives().stream() + .filter(c -> c.getType() == AdCreative.CreativeType.CARD) + .findFirst() + .orElse(selectedAd.getCreatives().get(0))); + + AffiliateAd response = new AffiliateAd(); + response.setId(selectedAd.getId()); + response.setTitle(selectedAd.getTitle()); + response.setLinkUrl(selectedAd.getLinkUrl()); + + String publicUrl = bestCreative.getImageUrl(); + if (publicUrl != null && !publicUrl.startsWith("http")) { + publicUrl = storageService.getPublicUrl(publicUrl); } + bestCreative.setImageUrl(publicUrl); + + response.setCreatives(List.of(bestCreative)); - return ad; + selectedAd.setViews(selectedAd.getViews() + 1); + adRepository.save(selectedAd); + + return response; + } + + private AdCreative.CreativeType parsePlacement(String placement) { + if (placement == null) return AdCreative.CreativeType.CARD; + switch (placement.toLowerCase()) { + case "banner": return AdCreative.CreativeType.BANNER; + case "sidebar": return AdCreative.CreativeType.SIDEBAR; + default: return AdCreative.CreativeType.CARD; + } } public void trackClick(String id) { @@ -54,46 +99,49 @@ public void trackClick(String id) { }); } - public AffiliateAd createAd(AffiliateAd ad, MultipartFile image) throws IOException { - if (image != null && !image.isEmpty()) { - String path = storageService.upload(image, "ads"); - ad.setImageUrl(path); - } + public AffiliateAd createAd(AffiliateAd ad, List images) throws IOException { + processImages(ad, images); return adRepository.save(ad); } public List getAllAds() { List ads = adRepository.findAll(); - // Resolve URLs for admin display ads.forEach(ad -> { - if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { - ad.setImageUrl(storageService.getPublicUrl(ad.getImageUrl())); + if (ad.getCreatives() != null) { + ad.getCreatives().forEach(c -> { + if (c.getImageUrl() != null && !c.getImageUrl().startsWith("http")) { + c.setImageUrl(storageService.getPublicUrl(c.getImageUrl())); + } + }); } }); return ads; } - public AffiliateAd updateAd(String id, AffiliateAd updated, MultipartFile image) throws IOException { + public AffiliateAd updateAd(String id, AffiliateAd updated, List newImages, List deleteCreativeIds) throws IOException { return adRepository.findById(id).map(ad -> { ad.setTitle(updated.getTitle()); ad.setLinkUrl(updated.getLinkUrl()); ad.setActive(updated.isActive()); - if (image != null && !image.isEmpty()) { - // Delete old image if it exists and isn't external - if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { - storageService.deleteFile(ad.getImageUrl()); - } - try { - String path = storageService.upload(image, "ads"); - ad.setImageUrl(path); - } catch (IOException e) { - logger.error("Failed to upload ad image", e); - } - } else if (updated.getImageUrl() != null) { - if (!updated.getImageUrl().equals(storageService.getPublicUrl(ad.getImageUrl()))) { - ad.setImageUrl(updated.getImageUrl()); + if (deleteCreativeIds != null && !deleteCreativeIds.isEmpty() && ad.getCreatives() != null) { + List toKeep = new ArrayList<>(); + for (AdCreative c : ad.getCreatives()) { + if (deleteCreativeIds.contains(c.getId())) { + if (c.getImageUrl() != null && !c.getImageUrl().startsWith("http")) { + storageService.deleteFile(c.getImageUrl()); + } + } else { + toKeep.add(c); + } } + ad.setCreatives(toKeep); + } + + try { + processImages(ad, newImages); + } catch (IOException e) { + logger.error("Failed to process new images", e); } return adRepository.save(ad); @@ -102,10 +150,35 @@ public AffiliateAd updateAd(String id, AffiliateAd updated, MultipartFile image) public void deleteAd(String id) { adRepository.findById(id).ifPresent(ad -> { - if (ad.getImageUrl() != null && !ad.getImageUrl().startsWith("http")) { - storageService.deleteFile(ad.getImageUrl()); + if (ad.getCreatives() != null) { + for (AdCreative c : ad.getCreatives()) { + if (c.getImageUrl() != null && !c.getImageUrl().startsWith("http")) { + storageService.deleteFile(c.getImageUrl()); + } + } } adRepository.deleteById(id); }); } + + private void processImages(AffiliateAd ad, List images) throws IOException { + if (images == null || images.isEmpty()) return; + + if (ad.getCreatives() == null) { + ad.setCreatives(new ArrayList<>()); + } + + for (MultipartFile file : images) { + if (file.isEmpty()) continue; + + BufferedImage bImg = ImageIO.read(file.getInputStream()); + int width = bImg != null ? bImg.getWidth() : 0; + int height = bImg != null ? bImg.getHeight() : 0; + + String path = storageService.upload(file, "ads"); + + AdCreative creative = new AdCreative(path, width, height); + ad.getCreatives().add(creative); + } + } } \ No newline at end of file diff --git a/frontend/src/components/admin/AdManagement.tsx b/frontend/src/components/admin/AdManagement.tsx index 7bf34773..54e15279 100644 --- a/frontend/src/components/admin/AdManagement.tsx +++ b/frontend/src/components/admin/AdManagement.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { api } from '../../utils/api'; -import { Plus, Trash2, Edit2, ExternalLink, BarChart2, Check, X, Upload, Image as ImageIcon } from 'lucide-react'; -import type { AffiliateAd } from '../../types'; +import { Plus, Trash2, Edit2, ExternalLink, BarChart2, X, Upload, Image as ImageIcon, Layout, Columns, RectangleHorizontal } from 'lucide-react'; +import type { AffiliateAd, AdCreative } from '../../types'; interface AdManagementProps { setStatus: (status: any) => void; @@ -14,14 +14,14 @@ export const AdManagement: React.FC = ({ setStatus }) => { const [editingAd, setEditingAd] = useState(null); const fileInputRef = useRef(null); - const [selectedFile, setSelectedFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(''); + const [selectedFiles, setSelectedFiles] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); + const [deletedCreativeIds, setDeletedCreativeIds] = useState([]); const [formData, setFormData] = useState({ title: '', linkUrl: '', - active: true, - imageUrl: '' + active: true }); useEffect(() => { @@ -29,12 +29,10 @@ export const AdManagement: React.FC = ({ setStatus }) => { }, []); useEffect(() => { - if (selectedFile) { - const url = URL.createObjectURL(selectedFile); - setPreviewUrl(url); - return () => URL.revokeObjectURL(url); - } - }, [selectedFile]); + return () => { + previewUrls.forEach(url => URL.revokeObjectURL(url)); + }; + }, [previewUrls]); const fetchAds = async () => { setLoading(true); @@ -55,12 +53,15 @@ export const AdManagement: React.FC = ({ setStatus }) => { data.append('ad', new Blob([JSON.stringify({ title: formData.title, linkUrl: formData.linkUrl, - active: formData.active, - imageUrl: formData.imageUrl + active: formData.active })], { type: 'application/json' })); - if (selectedFile) { - data.append('image', selectedFile); + selectedFiles.forEach(file => { + data.append('images', file); + }); + + if (deletedCreativeIds.length > 0) { + data.append('deleteIds', deletedCreativeIds.join(',')); } try { @@ -68,12 +69,12 @@ export const AdManagement: React.FC = ({ setStatus }) => { await api.put(`/admin/ads/${editingAd.id}`, data, { headers: { 'Content-Type': 'multipart/form-data' } }); - setStatus({ type: 'success', title: 'Updated', msg: 'Ad updated successfully.' }); + setStatus({ type: 'success', title: 'Updated', msg: 'Ad campaign updated.' }); } else { await api.post('/admin/ads', data, { headers: { 'Content-Type': 'multipart/form-data' } }); - setStatus({ type: 'success', title: 'Created', msg: 'Ad created successfully.' }); + setStatus({ type: 'success', title: 'Created', msg: 'Ad campaign created.' }); } setShowModal(false); resetForm(); @@ -84,13 +85,13 @@ export const AdManagement: React.FC = ({ setStatus }) => { }; const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this ad?')) return; + if (!confirm('Are you sure you want to delete this campaign?')) return; try { await api.delete(`/admin/ads/${id}`); - setStatus({ type: 'success', title: 'Deleted', msg: 'Ad deleted successfully.' }); + setStatus({ type: 'success', title: 'Deleted', msg: 'Campaign deleted.' }); fetchAds(); } catch (e) { - setStatus({ type: 'error', title: 'Error', msg: 'Failed to delete ad.' }); + setStatus({ type: 'error', title: 'Error', msg: 'Failed to delete.' }); } }; @@ -99,11 +100,11 @@ export const AdManagement: React.FC = ({ setStatus }) => { setFormData({ title: ad.title, linkUrl: ad.linkUrl, - active: ad.active !== undefined ? ad.active : true, - imageUrl: ad.imageUrl + active: ad.active }); - setPreviewUrl(ad.imageUrl); - setSelectedFile(null); + setSelectedFiles([]); + setPreviewUrls([]); + setDeletedCreativeIds([]); setShowModal(true); }; @@ -114,17 +115,28 @@ export const AdManagement: React.FC = ({ setStatus }) => { const resetForm = () => { setEditingAd(null); - setFormData({ title: '', linkUrl: '', active: true, imageUrl: '' }); - setSelectedFile(null); - setPreviewUrl(''); - if (fileInputRef.current) fileInputRef.current.value = ''; // Reset input value + setFormData({ title: '', linkUrl: '', active: true }); + setSelectedFiles([]); + setPreviewUrls([]); + setDeletedCreativeIds([]); + if (fileInputRef.current) fileInputRef.current.value = ''; }; const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { - const file = e.target.files[0]; - setSelectedFile(file); - setFormData(prev => ({...prev, imageUrl: ''})); + const files = Array.from(e.target.files); + setSelectedFiles(prev => [...prev, ...files]); + + const newPreviews = files.map(f => URL.createObjectURL(f)); + setPreviewUrls(prev => [...prev, ...newPreviews]); + } + }; + + const getIconForType = (type: string) => { + switch(type) { + case 'BANNER': return ; + case 'SIDEBAR': return ; + default: return ; } }; @@ -132,164 +144,144 @@ export const AdManagement: React.FC = ({ setStatus }) => {
-

Affiliate Links

-

Manage internal affiliate advertisements.

+

Ad Campaigns

+

Manage multi-format affiliate advertisements.

- +
- {loading ? ( -
Loading ads...
- ) : ( -
+ {loading ?
Loading...
: ( +
{ads.map((ad) => ( -
-
- {ad.imageUrl ? ( - {ad.title} - ) : ( -
- -
- )} -
- {ad.active ? 'Active' : 'Inactive'} +
+
+
+

{ad.title}

+ {ad.linkUrl}
+ {ad.active ? 'Active' : 'Inactive'}
-
-

{ad.title}

- - {ad.linkUrl} - -
-
{(ad as any).views || 0} Views
-
{(ad as any).clicks || 0} Clicks
+
+

Creatives ({ad.creatives?.length || 0})

+
+ {ad.creatives?.map(c => ( +
+ +
+
+ {getIconForType(c.type)} + {c.type} +
+
{c.width}x{c.height}
+
+
+ ))} + {(!ad.creatives || ad.creatives.length === 0) &&
No images uploaded.
}
+
+
+
+ {ad.views || 0} + {ad.clicks || 0} +
- - + +
))} - {ads.length === 0 && ( -
- No affiliate ads created yet. -
- )}
)} {showModal && (
-
+
-

{editingAd ? 'Edit Ad' : 'New Ad'}

+

{editingAd ? 'Edit Campaign' : 'New Campaign'}

-
-
- - setFormData({ ...formData, title: e.target.value })} - className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" - placeholder="Ad Campaign Name" - /> -
-
- -
fileInputRef.current?.click()} - className="relative w-full aspect-video rounded-xl bg-slate-100 dark:bg-black/20 border-2 border-dashed border-slate-300 dark:border-white/10 hover:border-modtale-accent hover:bg-slate-50 dark:hover:bg-white/5 transition-all cursor-pointer flex flex-col items-center justify-center group overflow-hidden" - > - {previewUrl ? ( - <> - Preview -
- Change Image -
- - ) : ( - <> - - Click to upload image - - )} +
+ +
+
+ + setFormData({ ...formData, title: e.target.value })} className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" /> +
+
+ + setFormData({ ...formData, linkUrl: e.target.value })} className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" /> +
- - {(!previewUrl && formData.imageUrl) && ( -
- Current external URL: {formData.imageUrl} + + {editingAd && editingAd.creatives && editingAd.creatives.length > 0 && ( +
+ +
+ {editingAd.creatives.map(c => { + const isDeleted = deletedCreativeIds.includes(c.id); + return ( +
+ +
+
+ {getIconForType(c.type)} + {c.type} +
+
{c.width}x{c.height}
+
+ +
+ ); + })} +
)} -
-
- - setFormData({ ...formData, linkUrl: e.target.value })} - className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white" - placeholder="https://..." - /> -
+
+ +
fileInputRef.current?.click()} className="border-2 border-dashed border-slate-300 dark:border-white/10 rounded-xl p-8 flex flex-col items-center justify-center text-slate-400 hover:text-modtale-accent hover:border-modtale-accent hover:bg-slate-50 dark:hover:bg-white/5 transition-all cursor-pointer"> + + Click to select images + Supports multiple files. Aspect ratio is auto-detected. +
+ -
- - Active -
+ {selectedFiles.length > 0 && ( +
+ {previewUrls.map((url, i) => ( +
+ + +
+ ))} +
+ )} +
-
- - -
- +
+ + Campaign Active +
+ +
+ +
+ + +
)} diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index a6a35bd6..85283e0c 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -1,30 +1,38 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState } from 'react'; import { api } from '../../utils/api'; import type { AffiliateAd } from '../../types'; import { ExternalLink } from 'lucide-react'; interface AdUnitProps { className?: string; - variant: 'card' | 'sidebar'; + variant: 'card' | 'sidebar' | 'banner'; } export const AdUnit: React.FC = ({ className, variant }) => { const [ad, setAd] = useState(null); const [loading, setLoading] = useState(true); const [useExternal, setUseExternal] = useState(false); - const adRef = useRef(null); useEffect(() => { let mounted = true; - const fetchAd = async () => { try { - const res = await api.get('/ads/serve'); + const placement = variant; + const res = await api.get(`/ads/serve?placement=${placement}`); + if (mounted) { if (res.status === 204) { setUseExternal(true); } else { - setAd(res.data); + const chosenCreative = res.data.creatives?.[0]; + if (chosenCreative) { + setAd({ + ...res.data, + imageUrl: chosenCreative.imageUrl + }); + } else { + setUseExternal(true); + } } setLoading(false); } @@ -35,10 +43,9 @@ export const AdUnit: React.FC = ({ className, variant }) => { } } }; - fetchAd(); return () => { mounted = false; }; - }, []); + }, [variant]); const handleClick = () => { if (ad) { @@ -50,30 +57,29 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (useExternal) { return ( -
-
- Advertisement -
+
+ Advertisement
); } - if (!ad) return null; + if (!ad || !ad.imageUrl) return null; + + if (variant === 'banner') { + return ( + + {ad.title} +
Ad
+
+ ); + } if (variant === 'card') { return ( - + {ad.title}
- - Sponsored - + Sponsored

{ad.title}

@@ -81,13 +87,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { } return ( - +
{ad.title}
Ad
diff --git a/frontend/src/react-pages/Home.tsx b/frontend/src/react-pages/Home.tsx index a484f1de..4e6c0169 100644 --- a/frontend/src/react-pages/Home.tsx +++ b/frontend/src/react-pages/Home.tsx @@ -385,6 +385,10 @@ export const Home: React.FC = ({
)} +
+ +
+ {totalPages > 1 && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 17074fb2..05a4e0a0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,11 +1,20 @@ +export interface AdCreative { + id: string; + imageUrl: string; + width: number; + height: number; + type: 'BANNER' | 'SIDEBAR' | 'CARD'; +} + export interface AffiliateAd { id: string; title: string; - imageUrl: string; linkUrl: string; active: boolean; + creatives: AdCreative[]; views?: number; clicks?: number; + imageUrl?: string; } export interface ConnectedAccount { From a9aa1a873c1aeb542a978e563d3b6853727eaaeb Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:40:36 -0500 Subject: [PATCH 17/28] Try to fix data seeding --- .../java/net/modtale/config/DataSeeder.java | 74 +++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/DataSeeder.java b/backend/src/main/java/net/modtale/config/DataSeeder.java index 30611f59..29aee6c1 100644 --- a/backend/src/main/java/net/modtale/config/DataSeeder.java +++ b/backend/src/main/java/net/modtale/config/DataSeeder.java @@ -50,6 +50,9 @@ public void run(String... args) { return; } + ensureSuperAdmin(); + ensureNormalUser(); + String currentDbName = mongoTemplate.getDb().getName(); if (currentDbName.equalsIgnoreCase(sourceDbName)) { @@ -57,26 +60,29 @@ public void run(String... args) { return; } - if (userRepository.count() > 0) { - logger.info("Database '{}' already contains data. Skipping seed.", currentDbName); + if (mongoTemplate.getCollection("projects").countDocuments() > 0) { + logger.info("Database '{}' already contains projects. Skipping content clone.", currentDbName); return; } logger.info("Initializing Preview Environment..."); - createSuperAdmin(); - createNormalUser(); - try { logger.info("Attempting to clone relational subset from '{}'...", sourceDbName); MongoDatabase sourceDb = mongoTemplate.getMongoDatabaseFactory().getMongoDatabase(sourceDbName); MongoDatabase targetDb = mongoTemplate.getDb(); + long sourceProjectCount = sourceDb.getCollection("projects").countDocuments(); + if (sourceProjectCount == 0) { + logger.warn("SOURCE DB '{}' IS EMPTY! Cannot clone data. Are you connecting to the correct cluster?", sourceDbName); + return; + } + List projects = fetchSubset(sourceDb, "projects", PROJECT_LIMIT); if (projects.isEmpty()) { - logger.info("No projects found in source. Seeding finished with just users."); + logger.info("No projects found in source (after count check). Seeding finished."); return; } @@ -90,7 +96,7 @@ public void run(String... args) { .filter(Objects::nonNull) .collect(Collectors.toSet()); - logger.info("Identified {} projects and {} unique authors to clone.", projects.size(), authorIds.size()); + logger.info("Found {} projects. Cloning referenced authors ({})...", projects.size(), authorIds.size()); cloneSpecificUsers(sourceDb, targetDb, authorIds); @@ -104,15 +110,19 @@ public void run(String... args) { logger.info("Seeding completed successfully."); } catch (Exception e) { - logger.error("Failed to seed preview database (Users were created, but cloning failed)", e); + logger.error("Failed to seed preview database", e); } } - private void createSuperAdmin() { - if (userRepository.existsById(SUPER_ADMIN_ID)) return; + private void ensureSuperAdmin() { + if (userRepository.existsById(SUPER_ADMIN_ID)) { + return; + } + + userRepository.findByUsername("super_admin").ifPresent(userRepository::delete); User user = new User(); - user.setId(SUPER_ADMIN_ID); // Explicitly set the secure ID + user.setId(SUPER_ADMIN_ID); user.setUsername("super_admin"); user.setEmail("admin@modtale.net"); user.setPassword(passwordEncoder.encode("password")); @@ -123,8 +133,9 @@ private void createSuperAdmin() { logger.info("Created Super Admin: super_admin / password (ID: {})", SUPER_ADMIN_ID); } - private void createNormalUser() { + private void ensureNormalUser() { if (userRepository.findByUsername("user").isPresent()) return; + User user = new User(); user.setUsername("user"); user.setEmail("user@modtale.net"); @@ -155,24 +166,37 @@ private void cloneSpecificUsers(MongoDatabase source, MongoDatabase target, Set< .filter(Objects::nonNull) .collect(Collectors.toList()); - List usersToClone = new ArrayList<>(); + if (objectIds.isEmpty()) return; + List usersToClone = new ArrayList<>(); sourceCol.find(Filters.in("_id", objectIds)).into(usersToClone); String defaultPasswordHash = passwordEncoder.encode("password"); + + List safeToInsert = new ArrayList<>(); + for (Document user : usersToClone) { String id = user.getObjectId("_id").toString(); - if (id.equals(SUPER_ADMIN_ID)) continue; + String username = user.getString("username"); + + if (id.equals(SUPER_ADMIN_ID) || "user".equals(username) || "super_admin".equals(username)) { + continue; + } user.put("password", defaultPasswordHash); user.put("email", "scrubbed_" + id + "@modtale.local"); user.put("githubAccessToken", null); user.put("gitlabAccessToken", null); + safeToInsert.add(user); } - if (!usersToClone.isEmpty()) { - targetCol.insertMany(usersToClone); - logger.info("Cloned and sanitized {} users.", usersToClone.size()); + if (!safeToInsert.isEmpty()) { + try { + targetCol.insertMany(safeToInsert); + logger.info("Cloned and sanitized {} users.", safeToInsert.size()); + } catch (Exception e) { + logger.warn("Partial user insertion error (likely duplicates): {}", e.getMessage()); + } } } @@ -193,8 +217,12 @@ private void cloneProjectStats(MongoDatabase source, MongoDatabase target, Set docs = fetchSubset(source, collectionName, limit); if (!docs.isEmpty()) { - target.getCollection(collectionName).insertMany(docs); - logger.info("Cloned {} documents from {}.", docs.size(), collectionName); + try { + target.getCollection(collectionName).insertMany(docs); + logger.info("Cloned {} documents from {}.", docs.size(), collectionName); + } catch (Exception e) { + logger.warn("Insertion error for {}: {}", collectionName, e.getMessage()); + } } } catch (Exception e) { logger.warn("Could not clone subset of {}", collectionName); From 735461b0572eccc1888b80f8684b4445793e8539 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:52:30 -0500 Subject: [PATCH 18/28] Fix Mongo error --- .../java/net/modtale/config/DataSeeder.java | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/net/modtale/config/DataSeeder.java b/backend/src/main/java/net/modtale/config/DataSeeder.java index 29aee6c1..1a22455e 100644 --- a/backend/src/main/java/net/modtale/config/DataSeeder.java +++ b/backend/src/main/java/net/modtale/config/DataSeeder.java @@ -75,14 +75,14 @@ public void run(String... args) { long sourceProjectCount = sourceDb.getCollection("projects").countDocuments(); if (sourceProjectCount == 0) { - logger.warn("SOURCE DB '{}' IS EMPTY! Cannot clone data. Are you connecting to the correct cluster?", sourceDbName); + logger.warn("SOURCE DB '{}' IS EMPTY! Cannot clone data.", sourceDbName); return; } List projects = fetchSubset(sourceDb, "projects", PROJECT_LIMIT); if (projects.isEmpty()) { - logger.info("No projects found in source (after count check). Seeding finished."); + logger.info("No projects found in source. Seeding finished."); return; } @@ -92,7 +92,7 @@ public void run(String... args) { .collect(Collectors.toSet()); Set projectIds = projects.stream() - .map(doc -> doc.getObjectId("_id")) + .map(doc -> getSafeObjectId(doc.get("_id"))) .filter(Objects::nonNull) .collect(Collectors.toSet()); @@ -100,8 +100,12 @@ public void run(String... args) { cloneSpecificUsers(sourceDb, targetDb, authorIds); - targetDb.getCollection("projects").insertMany(projects); - logger.info("Cloned {} projects.", projects.size()); + try { + targetDb.getCollection("projects").insertMany(projects); + logger.info("Cloned {} projects.", projects.size()); + } catch (Exception e) { + logger.warn("Project insertion warning (duplicates might exist): {}", e.getMessage()); + } cloneProjectStats(sourceDb, targetDb, projectIds); @@ -114,10 +118,21 @@ public void run(String... args) { } } - private void ensureSuperAdmin() { - if (userRepository.existsById(SUPER_ADMIN_ID)) { - return; + private ObjectId getSafeObjectId(Object id) { + if (id == null) return null; + if (id instanceof ObjectId) return (ObjectId) id; + if (id instanceof String) { + try { + return new ObjectId((String) id); + } catch (IllegalArgumentException e) { + return null; + } } + return null; + } + + private void ensureSuperAdmin() { + if (userRepository.existsById(SUPER_ADMIN_ID)) return; userRepository.findByUsername("super_admin").ifPresent(userRepository::delete); @@ -160,9 +175,7 @@ private void cloneSpecificUsers(MongoDatabase source, MongoDatabase target, Set< MongoCollection targetCol = target.getCollection("users"); List objectIds = userIds.stream() - .map(id -> { - try { return new ObjectId(id); } catch (IllegalArgumentException e) { return null; } - }) + .map(this::getSafeObjectId) .filter(Objects::nonNull) .collect(Collectors.toList()); @@ -171,12 +184,16 @@ private void cloneSpecificUsers(MongoDatabase source, MongoDatabase target, Set< List usersToClone = new ArrayList<>(); sourceCol.find(Filters.in("_id", objectIds)).into(usersToClone); - String defaultPasswordHash = passwordEncoder.encode("password"); + if (usersToClone.isEmpty()) { + List stringIds = userIds.stream().collect(Collectors.toList()); + sourceCol.find(Filters.in("_id", stringIds)).into(usersToClone); + } + String defaultPasswordHash = passwordEncoder.encode("password"); List safeToInsert = new ArrayList<>(); for (Document user : usersToClone) { - String id = user.getObjectId("_id").toString(); + String id = user.get("_id").toString(); String username = user.getString("username"); if (id.equals(SUPER_ADMIN_ID) || "user".equals(username) || "super_admin".equals(username)) { @@ -195,7 +212,7 @@ private void cloneSpecificUsers(MongoDatabase source, MongoDatabase target, Set< targetCol.insertMany(safeToInsert); logger.info("Cloned and sanitized {} users.", safeToInsert.size()); } catch (Exception e) { - logger.warn("Partial user insertion error (likely duplicates): {}", e.getMessage()); + logger.warn("Partial user insertion error: {}", e.getMessage()); } } } From 8a64ea326a898049bf82abdae3270a8866d2033d Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:11:29 -0500 Subject: [PATCH 19/28] Some placement adjustments --- frontend/src/components/ads/AdUnit.tsx | 4 ++-- frontend/src/react-pages/Home.tsx | 4 ++-- frontend/src/react-pages/resources/ModDetail.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index 85283e0c..aef55f2a 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -57,7 +57,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (useExternal) { return ( -
+
Advertisement
); @@ -76,7 +76,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'card') { return ( -
+ {ad.title}
Sponsored diff --git a/frontend/src/react-pages/Home.tsx b/frontend/src/react-pages/Home.tsx index 4e6c0169..2fd5c09e 100644 --- a/frontend/src/react-pages/Home.tsx +++ b/frontend/src/react-pages/Home.tsx @@ -259,7 +259,7 @@ export const Home: React.FC = ({ elements.push(
= ({ ); if (index === 5) { elements.push( -
+
); diff --git a/frontend/src/react-pages/resources/ModDetail.tsx b/frontend/src/react-pages/resources/ModDetail.tsx index 7f5cbce5..2b81cda3 100644 --- a/frontend/src/react-pages/resources/ModDetail.tsx +++ b/frontend/src/react-pages/resources/ModDetail.tsx @@ -141,7 +141,7 @@ const ProjectSidebar: React.FC<{
- + {gameVersions.length > 0 && ( From 581e0f557731b606a5697ca67d6d895798a51ba9 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:29:26 -0500 Subject: [PATCH 20/28] More placement adjustments --- frontend/src/components/ads/AdUnit.tsx | 60 ++++++++++++++++++++------ frontend/src/react-pages/Home.tsx | 2 + 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index aef55f2a..5867ee02 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -1,23 +1,24 @@ import React, { useEffect, useState } from 'react'; import { api } from '../../utils/api'; import type { AffiliateAd } from '../../types'; -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, X } from 'lucide-react'; interface AdUnitProps { className?: string; - variant: 'card' | 'sidebar' | 'banner'; + variant: 'card' | 'sidebar' | 'banner' | 'sticky-banner'; } export const AdUnit: React.FC = ({ className, variant }) => { const [ad, setAd] = useState(null); const [loading, setLoading] = useState(true); const [useExternal, setUseExternal] = useState(false); + const [isDismissed, setIsDismissed] = useState(false); useEffect(() => { let mounted = true; const fetchAd = async () => { try { - const placement = variant; + const placement = variant === 'sticky-banner' ? 'banner' : variant; const res = await api.get(`/ads/serve?placement=${placement}`); if (mounted) { @@ -53,8 +54,34 @@ export const AdUnit: React.FC = ({ className, variant }) => { } }; + if (isDismissed) return null; if (loading) return null; + if (variant === 'sticky-banner') { + if (!ad || !ad.imageUrl) return null; + return ( +
+ ); + } + if (useExternal) { return (
@@ -69,18 +96,25 @@ export const AdUnit: React.FC = ({ className, variant }) => { return ( {ad.title} -
Ad
+
Ad
); } if (variant === 'card') { return ( - - {ad.title} -
- Sponsored -

{ad.title}

+
+ {ad.title} + +
+ Ad +
+ +
+
+ {ad.title} + +
); @@ -90,11 +124,11 @@ export const AdUnit: React.FC = ({ className, variant }) => {
{ad.title} -
Ad
+
Ad
-
-

{ad.title}

-

Visit Site

+
+

{ad.title}

+
); diff --git a/frontend/src/react-pages/Home.tsx b/frontend/src/react-pages/Home.tsx index 2fd5c09e..46ab9a9d 100644 --- a/frontend/src/react-pages/Home.tsx +++ b/frontend/src/react-pages/Home.tsx @@ -407,6 +407,8 @@ export const Home: React.FC = ({
+ +
); }; \ No newline at end of file From c6d905004edd358fbcda0d760be9adb2aa7d4fff Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:40:57 -0500 Subject: [PATCH 21/28] Try to fit the image sizes better --- frontend/src/components/ads/AdUnit.tsx | 42 +++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index 5867ee02..877e9851 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -60,22 +60,35 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'sticky-banner') { if (!ad || !ad.imageUrl) return null; return ( -
-
+
+
- - {ad.title} -
Ad
-
@@ -95,8 +108,9 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'banner') { return ( - {ad.title} -
Ad
+ {/* Removed max-h to allow image to dictate height fully */} + {ad.title} +
Ad
); } @@ -123,8 +137,8 @@ export const AdUnit: React.FC = ({ className, variant }) => { return (
- {ad.title} -
Ad
+ {ad.title} +
Ad

{ad.title}

From fd0f9850e974767e236c663208bcd7fe549592d9 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:53:06 -0500 Subject: [PATCH 22/28] Unify the styling a little bit --- frontend/src/components/ads/AdUnit.tsx | 31 +++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index 877e9851..118bcacc 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -60,32 +60,29 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'sticky-banner') { if (!ad || !ad.imageUrl) return null; return ( -
-
+
+
- - {/* Auto width, constrained height to prevent blocking view */} + {ad.title} - {/* Minimal Hover Overlay */}
- + Visit Site
- {/* Tiny Badge */}
Ad
@@ -108,7 +105,6 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'banner') { return (
- {/* Removed max-h to allow image to dictate height fully */} {ad.title}
Ad
@@ -124,11 +120,10 @@ export const AdUnit: React.FC = ({ className, variant }) => { Ad
-
-
- {ad.title} - -
+
+ + Visit Site +
); @@ -139,6 +134,12 @@ export const AdUnit: React.FC = ({ className, variant }) => {
{ad.title}
Ad
+ +
+ + Visit + +

{ad.title}

From 64bc6b345963cd8ce7a3575fb7fde7c59e54d71e Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:09:31 -0500 Subject: [PATCH 23/28] Last few visual tweaks --- frontend/src/components/ads/AdUnit.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index 118bcacc..c9a16506 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -18,6 +18,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { let mounted = true; const fetchAd = async () => { try { + // Map variant to placement parameter const placement = variant === 'sticky-banner' ? 'banner' : variant; const res = await api.get(`/ads/serve?placement=${placement}`); @@ -60,7 +61,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'sticky-banner') { if (!ad || !ad.imageUrl) return null; return ( -
+
@@ -217,6 +221,18 @@ export const AdManagement: React.FC = ({ setStatus }) => {
+
+ + setFormData({ ...formData, trackingParam: e.target.value })} + className="w-full px-4 py-2 rounded-lg bg-slate-50 dark:bg-black/20 border border-slate-200 dark:border-white/10 focus:ring-2 focus:ring-modtale-accent outline-none text-slate-900 dark:text-white font-mono text-sm" + placeholder="e.g. r, ref, utm_source" + /> +

If set, Modtale will append ?{formData.trackingParam || 'param'}=PageSource to the URL.

+
+ {editingAd && editingAd.creatives && editingAd.creatives.length > 0 && (
diff --git a/frontend/src/components/ads/AdUnit.tsx b/frontend/src/components/ads/AdUnit.tsx index f82a7e65..a65ee756 100644 --- a/frontend/src/components/ads/AdUnit.tsx +++ b/frontend/src/components/ads/AdUnit.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { api } from '../../utils/api'; import type { AffiliateAd } from '../../types'; import { ExternalLink, X } from 'lucide-react'; @@ -6,9 +6,10 @@ import { ExternalLink, X } from 'lucide-react'; interface AdUnitProps { className?: string; variant: 'card' | 'sidebar' | 'banner' | 'sticky-banner'; + trackingSource?: string; } -export const AdUnit: React.FC = ({ className, variant }) => { +export const AdUnit: React.FC = ({ className, variant, trackingSource }) => { const [ad, setAd] = useState(null); const [loading, setLoading] = useState(true); const [useExternal, setUseExternal] = useState(false); @@ -54,6 +55,15 @@ export const AdUnit: React.FC = ({ className, variant }) => { } }; + const finalUrl = useMemo(() => { + if (!ad || !ad.linkUrl) return ''; + if (!ad.trackingParam || !trackingSource) return ad.linkUrl; + + const cleanSource = encodeURIComponent(trackingSource); + const separator = ad.linkUrl.includes('?') ? '&' : '?'; + return `${ad.linkUrl}${separator}${ad.trackingParam}=${cleanSource}`; + }, [ad, trackingSource]); + if (isDismissed) return null; if (loading) return null; @@ -70,7 +80,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { - + {ad.title} = ({ className, variant }) => { if (useExternal) { return ( -
+
Advertisement
); @@ -104,7 +114,7 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'banner') { return ( -
+ {ad.title}
Ad
@@ -113,8 +123,8 @@ export const AdUnit: React.FC = ({ className, variant }) => { if (variant === 'card') { return ( - - {ad.title} + + {ad.title} @@ -371,7 +371,7 @@ export const Home: React.FC = ({ )}
- +
{totalPages > 1 && ( diff --git a/frontend/src/react-pages/resources/ModDetail.tsx b/frontend/src/react-pages/resources/ModDetail.tsx index 2b81cda3..cca691d9 100644 --- a/frontend/src/react-pages/resources/ModDetail.tsx +++ b/frontend/src/react-pages/resources/ModDetail.tsx @@ -141,7 +141,7 @@ const ProjectSidebar: React.FC<{
- + {gameVersions.length > 0 && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 05a4e0a0..a3cba844 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,7 @@ export interface AffiliateAd { linkUrl: string; active: boolean; creatives: AdCreative[]; + trackingParam?: string; views?: number; clicks?: number; imageUrl?: string;