diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b05fed71..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' @@ -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 }}" ) diff --git a/backend/src/main/java/net/modtale/config/DataSeeder.java b/backend/src/main/java/net/modtale/config/DataSeeder.java index f2c76b05..1a22455e 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; @@ -48,6 +50,9 @@ public void run(String... args) { return; } + ensureSuperAdmin(); + ensureNormalUser(); + String currentDbName = mongoTemplate.getDb().getName(); if (currentDbName.equalsIgnoreCase(sourceDbName)) { @@ -55,23 +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 with relational subset from '{}'...", sourceDbName); + logger.info("Initializing Preview Environment..."); try { + logger.info("Attempting to clone relational subset from '{}'...", sourceDbName); + MongoDatabase sourceDb = mongoTemplate.getMongoDatabaseFactory().getMongoDatabase(sourceDbName); MongoDatabase targetDb = mongoTemplate.getDb(); - createDevAdmin(); + long sourceProjectCount = sourceDb.getCollection("projects").countDocuments(); + if (sourceProjectCount == 0) { + 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. Seeding finished early."); + logger.info("No projects found in source. Seeding finished."); return; } @@ -81,16 +92,20 @@ 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()); - 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); - 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); @@ -103,15 +118,48 @@ public void run(String... args) { } } - private void createDevAdmin() { + 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); + User user = new User(); - user.setUsername("dev_admin"); + user.setId(SUPER_ADMIN_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 Default Admin: dev_admin / password"); + logger.info("Created Super Admin: super_admin / password (ID: {})", SUPER_ADMIN_ID); + } + + private void ensureNormalUser() { + 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 Normal User: user / password"); } private List fetchSubset(MongoDatabase db, String collectionName, int limit) { @@ -127,27 +175,45 @@ 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()); - List usersToClone = new ArrayList<>(); + if (objectIds.isEmpty()) return; + List usersToClone = new ArrayList<>(); sourceCol.find(Filters.in("_id", objectIds)).into(usersToClone); + 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.get("_id").toString(); + 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_" + user.getObjectId("_id").toString() + "@modtale.local"); + 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: {}", e.getMessage()); + } } } @@ -168,8 +234,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); 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) 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..c9dbf2f9 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,14 +80,31 @@ 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 SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - CookieCsrfTokenRepository tokenRepository = new CookieCsrfTokenRepository(); - tokenRepository.setCookieHttpOnly(false); - tokenRepository.setSecure(true); - tokenRepository.setCookiePath("/"); - tokenRepository.setCookieCustomizer(cookie -> { - cookie.sameSite("None"); + 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(); @@ -93,13 +112,45 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti 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); + serializer.setDomainName(rootDomain); } } } catch (Exception e) { - logger.warn("Failed to set CSRF cookie domain: {}", e.getMessage()); + 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 -> { + boolean isPreview = isPreviewEnvironment(); + + if (isPreview) { + cookie.sameSite("None"); + cookie.domain(null); + } 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); + } + } + } catch (Exception e) { + logger.warn("Failed to set CSRF cookie domain: {}", e.getMessage()); + } } } }); @@ -110,12 +161,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) @@ -150,6 +208,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ).permitAll() .requestMatchers("/sitemap.xml", "/actuator/health").permitAll() .requestMatchers("/client-metadata.json").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( @@ -157,7 +216,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")); @@ -210,9 +270,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")); @@ -236,6 +304,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")); 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 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 new file mode 100644 index 00000000..1fa73c71 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/ad/AdController.java @@ -0,0 +1,98 @@ +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.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/v1") +public class AdController { + + @Autowired + private AdService adService; + + @Autowired + private UserService userService; + + private static final String SUPER_ADMIN_ID = "692620f7c2f3266e23ac0ded"; + + 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(@RequestParam(required = false, defaultValue = "card") String placement) { + AffiliateAd ad = adService.getRandomAd(placement); + if (ad == null) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(ad); + } + + @PostMapping("/ads/{id}/click") + public ResponseEntity trackClick(@PathVariable String id) { + adService.trackClick(id); + return ResponseEntity.ok().build(); + } + + @GetMapping("/admin/ads") + 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) + public ResponseEntity createAd( + @RequestPart("ad") AffiliateAd ad, + @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, images)); + } + + @PutMapping(value = "/admin/ads/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateAd( + @PathVariable String id, + @RequestPart("ad") AffiliateAd ad, + @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, images, deleteIds)); + } + + @DeleteMapping("/admin/ads/{id}") + 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(); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..ed5e1430 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/ad/AffiliateAd.java @@ -0,0 +1,53 @@ +package net.modtale.model.ad; + +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 linkUrl; + private boolean active; + + private String trackingParam; + + private List creatives = new ArrayList<>(); + + private int views; + private int clicks; + + public AffiliateAd() {} + + public AffiliateAd(String title, String linkUrl) { + this.title = title; + 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 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 String getTrackingParam() { return trackingParam; } + public void setTrackingParam(String trackingParam) { this.trackingParam = trackingParam; } + + 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/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..84eb37b6 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/ad/AdService.java @@ -0,0 +1,182 @@ +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; +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 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 { + + 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(String placement) { + 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 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()); + response.setTrackingParam(selectedAd.getTrackingParam()); + + String publicUrl = bestCreative.getImageUrl(); + if (publicUrl != null && !publicUrl.startsWith("http")) { + publicUrl = storageService.getPublicUrl(publicUrl); + } + bestCreative.setImageUrl(publicUrl); + + response.setCreatives(List.of(bestCreative)); + + 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) { + adRepository.findById(id).ifPresent(ad -> { + ad.setClicks(ad.getClicks() + 1); + adRepository.save(ad); + }); + } + + public AffiliateAd createAd(AffiliateAd ad, List images) throws IOException { + processImages(ad, images); + return adRepository.save(ad); + } + + public List getAllAds() { + List ads = adRepository.findAll(); + ads.forEach(ad -> { + 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, List newImages, List deleteCreativeIds) throws IOException { + return adRepository.findById(id).map(ad -> { + ad.setTitle(updated.getTitle()); + ad.setLinkUrl(updated.getLinkUrl()); + ad.setActive(updated.isActive()); + ad.setTrackingParam(updated.getTrackingParam()); + + 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); + }).orElse(null); + } + + public void deleteAd(String id) { + adRepository.findById(id).ifPresent(ad -> { + 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/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); } } diff --git a/frontend/src/components/admin/AdManagement.tsx b/frontend/src/components/admin/AdManagement.tsx new file mode 100644 index 00000000..471fc3cf --- /dev/null +++ b/frontend/src/components/admin/AdManagement.tsx @@ -0,0 +1,306 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { api } from '../../utils/api'; +import { Plus, Trash2, Edit2, ExternalLink, BarChart2, X, Upload, RectangleHorizontal, Columns, Layout } 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 [selectedFiles, setSelectedFiles] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); + const [deletedCreativeIds, setDeletedCreativeIds] = useState([]); + + const [formData, setFormData] = useState({ + title: '', + linkUrl: '', + trackingParam: '', // New field + active: true + }); + + useEffect(() => { + fetchAds(); + }, []); + + useEffect(() => { + return () => { + previewUrls.forEach(url => URL.revokeObjectURL(url)); + }; + }, [previewUrls]); + + 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, + trackingParam: formData.trackingParam, + active: formData.active + })], { type: 'application/json' })); + + selectedFiles.forEach(file => { + data.append('images', file); + }); + + if (deletedCreativeIds.length > 0) { + data.append('deleteIds', deletedCreativeIds.join(',')); + } + + try { + if (editingAd) { + await api.put(`/admin/ads/${editingAd.id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + 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 campaign created.' }); + } + 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 campaign?')) return; + try { + await api.delete(`/admin/ads/${id}`); + setStatus({ type: 'success', title: 'Deleted', msg: 'Campaign deleted.' }); + fetchAds(); + } catch (e) { + setStatus({ type: 'error', title: 'Error', msg: 'Failed to delete.' }); + } + }; + + const openEdit = (ad: AffiliateAd) => { + setEditingAd(ad); + setFormData({ + title: ad.title, + linkUrl: ad.linkUrl, + trackingParam: ad.trackingParam || '', + active: ad.active + }); + setSelectedFiles([]); + setPreviewUrls([]); + setDeletedCreativeIds([]); + setShowModal(true); + }; + + const openCreate = () => { + resetForm(); + setShowModal(true); + }; + + const resetForm = () => { + setEditingAd(null); + setFormData({ title: '', linkUrl: '', trackingParam: '', 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 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 ; + } + }; + + return ( +
+
+
+

Ad Campaigns

+

Manage multi-format affiliate advertisements.

+
+ +
+ + {loading ?
Loading...
: ( +
+ {ads.map((ad) => ( +
+
+
+

{ad.title}

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

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} + {ad.trackingParam && ?{ad.trackingParam}=...} +
+
+ + +
+
+
+ ))} +
+ )} + + {showModal && ( +
+
+
+

{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" /> +
+
+ + 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" /> +
+
+ +
+ + 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 && ( +
+ +
+ {editingAd.creatives.map(c => { + const isDeleted = deletedCreativeIds.includes(c.id); + return ( +
+ +
+
+ {getIconForType(c.type)} + {c.type} +
+
{c.width}x{c.height}
+
+ +
+ ); + })} +
+
+ )} + +
+ +
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. +
+ + + {selectedFiles.length > 0 && ( +
+ {previewUrls.map((url, i) => ( +
+ + +
+ ))} +
+ )} +
+ +
+ + Campaign Active +
+
+
+ +
+ + +
+
+
+ )} +
+ ); +}; \ 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..a65ee756 --- /dev/null +++ b/frontend/src/components/ads/AdUnit.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { api } from '../../utils/api'; +import type { AffiliateAd } from '../../types'; +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, trackingSource }) => { + 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 === 'sticky-banner' ? 'banner' : variant; + const res = await api.get(`/ads/serve?placement=${placement}`); + + if (mounted) { + if (res.status === 204) { + setUseExternal(true); + } else { + const chosenCreative = res.data.creatives?.[0]; + if (chosenCreative) { + setAd({ + ...res.data, + imageUrl: chosenCreative.imageUrl + }); + } else { + setUseExternal(true); + } + } + setLoading(false); + } + } catch (error) { + if (mounted) { + setUseExternal(true); + setLoading(false); + } + } + }; + fetchAd(); + return () => { mounted = false; }; + }, [variant]); + + const handleClick = () => { + if (ad) { + api.post(`/ads/${ad.id}/click`).catch(() => {}); + } + }; + + 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; + + if (variant === 'sticky-banner') { + if (!ad || !ad.imageUrl) return null; + return ( +
+
+ + + + {ad.title} + +
+ + Visit Site + +
+ +
+ Ad +
+
+
+
+ ); + } + + if (useExternal) { + return ( +
+ Advertisement +
+ ); + } + + if (!ad || !ad.imageUrl) return null; + + if (variant === 'banner') { + return ( + + {ad.title} +
Ad
+
+ ); + } + + if (variant === 'card') { + return ( + + {ad.title} + +
+ Ad +
+ +
+ + Visit Site + +
+
+ ); + } + + return ( + +
+ {ad.title} +
Ad
+ +
+ + Visit + +
+
+
+

{ad.title}

+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/context/ExternalLinkContext.tsx b/frontend/src/context/ExternalLinkContext.tsx index cf650937..e68d4c86 100644 --- a/frontend/src/context/ExternalLinkContext.tsx +++ b/frontend/src/context/ExternalLinkContext.tsx @@ -55,6 +55,10 @@ export const ExternalLinkProvider: React.FC<{ children: React.ReactNode }> = ({ if (!anchor.href.startsWith('http')) return; + if (anchor.getAttribute('rel')?.includes('nofollow')) { + return; + } + try { const url = new URL(anchor.href); const currentHost = window.location.hostname; 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 }) => (