from datetime import timedelta from uuid import UUID from fastapi import HTTPException, status from sqlalchemy import Select, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col from app.services.target import TargetService from shared.definitions.bounty_feed import ( FEEDS_BY_PLATFORM, SOURCE_LICENSE, SOURCE_NAME, SOURCE_URL, ) from shared.definitions.bounty_programs import ( API_PLATFORMS, DEFAULT_FEED_INTERVAL, DEFAULT_SYNC_INTERVAL, MAX_TAGS_PER_IMPORT, NOTIFIABLE_EVENTS, PLATFORMS, PLATFORMS_BY_KEY, SOURCE_LABELS, SYNC_INTERVAL_HOURS, ProgramSource, ProgramState, ScopeState, SubmissionState, SyncInterval, asset_type_spec, event_spec, notify_enabled, notify_events, raw_state_label, ) from shared.enums.api_key import APIProvider from shared.models.api_key import APIKey from shared.models.bounty_program import ( BountyEventRead, BountyEventRow, BountyImportRequest, BountyImportResult, BountyProgram, BountyProgramDetail, BountyProgramRead, BountyScope, BountyScopeRead, BountySettingsRead, BountySettingsUpdate, BountyStatus, PlatformCount, ) from shared.models.instance_settings import InstanceSettings from shared.models.organization import Organization, OrganizationSummary from shared.models.tag import Tag, TagSummary, TargetTag from shared.models.target import Target, TargetOrganization from shared.models.watch import ProgramWatch from shared.utils.datetime import utc_now from shared.utils.slug import add_with_unique_slug from shared.utils.validation import clean_name def _platform_label(platform: str) -> str: spec = PLATFORMS_BY_KEY.get(platform) return spec.label if spec else platform MAX_ORG_NAME = 100 MAX_TAG_NAME = 50 DEFAULT_TAG_COLOR = "#6B7280" MAX_ORG_DESCRIPTION = 500 EMPTY_COUNTS = {"in_scope_count": 0, "out_of_scope_count": 0, "importable_count": 0} def _without_counts(program) -> dict: """paginate() may hand back an already-coerced read.""" data = program.model_dump() for key in ( *EMPTY_COUNTS, "source_labels", "follow_label", "imported_count", "watched", "watch_id", "scopes", "unreachable", "raw_state_label", ): data.pop(key, None) data["raw_state_label"] = raw_state_label(data.get("raw_state")) spec = PLATFORMS_BY_KEY.get(data.get("platform") or "") data["platform_label"] = spec.label if spec else (data.get("platform") or "") data["follow_label"] = spec.follow_label if spec else "" data["source_label"] = SOURCE_LABELS.get(data.get("source") or "", "") sources = data.get("sources") or ([data["source"]] if data.get("source") else []) data["sources"] = sources data["source_labels"] = [SOURCE_LABELS.get(s, s) for s in sources] return data SORTS = { "name": (col(BountyProgram.name), "asc"), "reports": (col(BountyProgram.reports_for_user), "desc"), "age": (col(BountyProgram.started_accepting_at), "desc"), "payout": (col(BountyProgram.max_payout), "desc"), "assets": (col(BountyProgram.name), "asc"), } class BountyProgramService: def __init__(self, session: AsyncSession): self.session = session async def _connected(self) -> dict[str, dict]: """Every platform credential this instance holds, keyed by provider.""" wanted = [APIProvider(p.api_provider) for p in API_PLATFORMS if p.api_provider] rows = await self.session.execute( select(APIKey).where( col(APIKey.provider).in_(wanted), APIKey.is_enabled == True, # noqa: E712 ) ) return {k.provider.value: (k.key_meta or {}) for k in rows.scalars()} async def _credentials_username(self, platform: str) -> str | None: spec = PLATFORMS_BY_KEY.get(platform) if not spec or not spec.api_provider: return None meta = (await self._connected()).get(spec.api_provider) if meta is None: return None return meta.get("username") or spec.label async def _settings(self) -> InstanceSettings | None: rows = await self.session.execute(select(InstanceSettings).limit(1)) return rows.scalar_one_or_none() async def events( self, platform: str | None, *, kind: str | None, handle: str | None ) -> Select: query = select(BountyEventRow) if platform: query = query.where(BountyEventRow.platform == platform) if kind: query = query.where(BountyEventRow.kind == kind) if handle: query = query.where(BountyEventRow.handle == handle) return query.order_by(col(BountyEventRow.created_at).desc()) @staticmethod def event_read(row: BountyEventRow) -> BountyEventRead: spec = event_spec(row.kind) return BountyEventRead( id=row.id, platform=row.platform, handle=row.handle, program_name=row.program_name, kind=row.kind, label=spec.label, description=spec.description, icon=spec.icon, tone=spec.tone, actionable=spec.actionable, asset_type=row.asset_type, asset_identifier=row.asset_identifier, detail=row.detail, created_at=row.created_at, ) async def read_settings(self) -> BountySettingsRead: settings = await self._settings() interval = ( settings.bounty_sync_interval if settings else None ) or DEFAULT_SYNC_INTERVAL stored = settings.bounty_settings if settings else {} synced_at = settings.bounty_synced_at if settings else None hours = SYNC_INTERVAL_HOURS.get(interval) totals = await self.session.execute( select( select(func.count(BountyProgram.id)).scalar_subquery(), select(func.count(BountyEventRow.id)).scalar_subquery(), ) ) programs, events = totals.one() feed_interval = ( settings.bounty_feed_interval if settings else None ) or DEFAULT_FEED_INTERVAL feed_synced = settings.bounty_feed_synced_at if settings else None feed_hours = SYNC_INTERVAL_HOURS.get(feed_interval) feed_programs = await self.session.execute( select(func.count(BountyProgram.id)).where( BountyProgram.source == ProgramSource.FEED.value ) ) return BountySettingsRead( platforms=await self.platform_counts(), sync_interval=interval, feed_interval=feed_interval, feed_synced_at=feed_synced, feed_next_sync_at=( (feed_synced + timedelta(hours=feed_hours)) if (feed_hours and feed_synced) else None ), feed_programs=feed_programs.scalar_one() or 0, feed_source=SOURCE_NAME, feed_url=SOURCE_URL, feed_license=SOURCE_LICENSE, notify=notify_enabled(stored), notify_events=sorted(notify_events(stored)), notifiable_events=list(NOTIFIABLE_EVENTS), last_synced_at=synced_at, next_sync_at=( (synced_at + timedelta(hours=hours)) if (hours and synced_at) else None ), programs=programs or 0, events_recorded=events or 0, ) async def write_settings(self, data: BountySettingsUpdate) -> BountySettingsRead: settings = await self._settings() if settings is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Instance settings not found", ) if data.sync_interval is not None: if data.sync_interval not in {i.value for i in SyncInterval}: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown sync interval: {data.sync_interval}", ) settings.bounty_sync_interval = data.sync_interval if data.feed_interval is not None: if data.feed_interval not in {i.value for i in SyncInterval}: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown sync interval: {data.feed_interval}", ) settings.bounty_feed_interval = data.feed_interval stored = dict(settings.bounty_settings or {}) if data.notify is not None: stored["notify"] = data.notify if data.notify_events is not None: unknown = set(data.notify_events) - set(NOTIFIABLE_EVENTS) if unknown: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown change kinds: {', '.join(sorted(unknown))}", ) stored["notify_events"] = sorted(set(data.notify_events)) settings.bounty_settings = stored await self.session.commit() return await self.read_settings() async def mark_events_seen(self) -> None: settings = await self._settings() if settings: settings.bounty_events_seen_at = utc_now() await self.session.commit() async def platform_counts(self) -> list[PlatformCount]: rows = await self.session.execute( select( BountyProgram.platform, func.count(BountyProgram.id), func.count(BountyProgram.id).filter( BountyProgram.program_state == ProgramState.PRIVATE.value ), func.count(BountyProgram.id).filter( BountyProgram.source == ProgramSource.FEED.value ), ).group_by(BountyProgram.platform) ) counts = {r[0]: r[1:] for r in rows.all()} empty = (0, 0, 0) connected = await self._connected() return [ PlatformCount( platform=spec.key, label=spec.label, source=spec.source, programs=counts.get(spec.key, empty)[0], private_programs=counts.get(spec.key, empty)[1], feed_programs=counts.get(spec.key, empty)[2], has_feed=spec.key in FEEDS_BY_PLATFORM, api_provider=spec.api_provider, supports_private=spec.supports_private, credential=spec.credential, note=spec.note, configured=bool(spec.api_provider and spec.api_provider in connected), tracks_reports=spec.tracks_reports, ) for spec in PLATFORMS ] async def source_counts(self) -> dict[str, int]: """Programs each source has seen, counting a program under every source.""" rows = await self.session.execute( select( *[ func.count(BountyProgram.id).filter( col(BountyProgram.sources).contains([source.value]) ) for source in ProgramSource ] ) ) return dict(zip([s.value for s in ProgramSource], rows.one(), strict=True)) async def status(self, platform: str) -> BountyStatus: username = await self._credentials_username(platform) settings = await self._settings() interval = (settings.bounty_sync_interval if settings else None) or ( DEFAULT_SYNC_INTERVAL ) synced_at = settings.bounty_synced_at if settings else None seen_at = settings.bounty_events_seen_at if settings else None hours = SYNC_INTERVAL_HOURS.get(interval) next_sync = ( (synced_at + timedelta(hours=hours)) if (hours and synced_at) else None ) unseen = await self.session.execute( select(func.count(BountyEventRow.id)).where( *([BountyEventRow.created_at > seen_at] if seen_at else []), ) ) platforms = await self.platform_counts() source_counts = await self.source_counts() totals = await self.session.execute( select( func.count(BountyProgram.id), func.count(BountyProgram.id).filter( BountyProgram.program_state == ProgramState.PRIVATE.value ), func.max(BountyProgram.synced_at), ) ) total, private, last = totals.one() return BountyStatus( configured=bool(username), platform=platform, username=username, programs=total or 0, private_programs=private or 0, last_synced_at=synced_at or last, sync_interval=interval, next_sync_at=next_sync, unseen_events=unseen.scalar_one() or 0, platforms=platforms, source_counts=source_counts, feed_interval=(settings.bounty_feed_interval if settings else None) or DEFAULT_FEED_INTERVAL, feed_synced_at=settings.bounty_feed_synced_at if settings else None, ) def _filtered( self, platform: str | None, *, platforms: list[str] | None, sources: list[str] | None, q: str | None, state: str | None, submission: str | None, bounty: bool | None, bookmarked: bool | None, joined: bool | None, scope: str | None, ) -> Select: query = select(BountyProgram) if platform: query = query.where(BountyProgram.platform == platform) if platforms: query = query.where(col(BountyProgram.platform).in_(platforms)) if sources: query = query.where( or_(*[col(BountyProgram.sources).contains([s]) for s in sources]) ) if q: term = f"%{q.strip().lower()}%" query = query.where( or_( func.lower(BountyProgram.name).like(term), func.lower(BountyProgram.handle).like(term), ) ) if state in {s.value for s in ProgramState}: query = query.where(BountyProgram.program_state == state) if submission in {s.value for s in SubmissionState}: query = query.where(BountyProgram.submission_state == submission) if bounty is not None: query = query.where(BountyProgram.offers_bounties == bounty) if bookmarked is not None: query = query.where(BountyProgram.bookmarked == bookmarked) if joined is not None: query = query.where(BountyProgram.joined == joined) if scope == "importable": query = query.where( select(BountyScope.id) .where( BountyScope.program_id == BountyProgram.id, BountyScope.scope_state == ScopeState.IN_SCOPE.value, col(BountyScope.target_value).is_not(None), ) .exists() ) elif scope == "none": query = query.where( ~select(BountyScope.id) .where(BountyScope.program_id == BountyProgram.id) .exists() ) return query def list_query(self, platform: str | None, *, sort: str, **filters) -> Select: query = self._filtered(platform, **filters) column, default_dir = SORTS.get(sort or "age", SORTS["age"]) ordering = column.desc() if default_dir == "desc" else column.asc() return query.order_by(ordering.nullslast(), col(BountyProgram.name).asc()) async def imported_for( self, program_ids: list[UUID], project_id: UUID | None ) -> dict[UUID, int]: """How many of each program's assets the project already tracks.""" if not program_ids or not project_id: return {} rows = await self.session.execute( select( BountyScope.program_id, func.count(func.distinct(BountyScope.target_value)), ) .join( Target, (Target.target_value == BountyScope.target_value) & (Target.project_id == project_id), ) .where(col(BountyScope.program_id).in_(program_ids)) .group_by(BountyScope.program_id) ) return {program_id: count or 0 for program_id, count in rows.all()} async def counts_for(self, program_ids: list[UUID]) -> dict[UUID, dict[str, int]]: """Scope tallies per program.""" if not program_ids: return {} rows = await self.session.execute( select( BountyScope.program_id, func.count(BountyScope.id), func.count(BountyScope.id).filter( BountyScope.scope_state == ScopeState.IN_SCOPE.value ), func.count(BountyScope.id).filter( col(BountyScope.target_value).is_not(None), BountyScope.scope_state == ScopeState.IN_SCOPE.value, ), ) .where(col(BountyScope.program_id).in_(program_ids)) .group_by(BountyScope.program_id) ) out: dict[UUID, dict[str, int]] = {} for program_id, total, in_scope, importable in rows.all(): out[program_id] = { "in_scope_count": in_scope or 0, "out_of_scope_count": (total or 0) - (in_scope or 0), "importable_count": importable or 0, } return out async def to_read( self, programs: list[BountyProgram], project_id: UUID | None = None ) -> list[BountyProgramRead]: ids = [p.id for p in programs] counts = await self.counts_for(ids) imported = await self.imported_for(ids, project_id) watched = await self.watched_for(ids, project_id) return [ BountyProgramRead( **_without_counts(p), **counts.get(p.id, EMPTY_COUNTS), imported_count=imported.get(p.id, 0), watched=p.id in watched, watch_id=watched.get(p.id), ) for p in programs ] async def watched_for( self, program_ids: list[UUID], project_id: UUID | None ) -> dict[UUID, UUID]: if not project_id or not program_ids: return {} rows = await self.session.execute( select(ProgramWatch.program_id, ProgramWatch.id).where( ProgramWatch.project_id == project_id, ProgramWatch.program_id.in_(program_ids), ) ) return dict(rows.all()) async def get_program(self, platform: str, handle: str) -> BountyProgram: row = await self.session.execute( select(BountyProgram).where( BountyProgram.platform == platform, BountyProgram.handle == handle ) ) program = row.scalar_one_or_none() if not program: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Program not found" ) return program async def detail( self, platform: str, handle: str, *, project_id: UUID | None, scope: str | None = None, asset_type: str | None = None, ) -> BountyProgramDetail: program = await self.get_program(platform, handle) query = select(BountyScope).where(BountyScope.program_id == program.id) if scope in {s.value for s in ScopeState}: query = query.where(BountyScope.scope_state == scope) if asset_type: query = query.where(BountyScope.asset_type == asset_type.upper()) rows = (await self.session.execute(query)).scalars().all() existing: set[str] = set() if project_id: values = [s.target_value for s in rows if s.target_value] if values: found = await self.session.execute( select(Target.target_value).where( Target.project_id == project_id, col(Target.target_value).in_(values), ) ) existing = set(found.scalars().all()) scopes = [self._scope_read(s, existing) for s in rows] scopes.sort( key=lambda s: ( s.scope_state != ScopeState.IN_SCOPE.value, not s.importable, s.asset_identifier, ) ) counts = await self.counts_for([program.id]) unreachable: dict[str, int] = {} for s in rows: if s.target_value: continue label = asset_type_spec(s.asset_type).label unreachable[label] = unreachable.get(label, 0) + 1 return BountyProgramDetail( **_without_counts(program), **counts.get(program.id, EMPTY_COUNTS), imported_count=len(existing), scopes=scopes, unreachable=unreachable, ) @staticmethod def _scope_read(scope: BountyScope, existing: set[str]) -> BountyScopeRead: spec = asset_type_spec(scope.asset_type) return BountyScopeRead( id=scope.id, asset_type=scope.asset_type, asset_type_label=spec.label, asset_group=spec.group.value, icon=spec.icon, asset_identifier=scope.asset_identifier, scope_state=scope.scope_state, eligible_for_bounty=scope.eligible_for_bounty, max_severity=scope.max_severity, tier=scope.tier, instruction=scope.instruction, target_value=scope.target_value, target_type=scope.target_type.value if scope.target_type else None, importable=bool(scope.target_value), already_target=bool(scope.target_value and scope.target_value in existing), ) async def import_scopes( self, platform: str, handle: str, request: BountyImportRequest, user_id ) -> BountyImportResult: """Add a program's scannable assets to a project as targets.""" program = await self.get_program(platform, handle) query = select(BountyScope).where( BountyScope.program_id == program.id, col(BountyScope.target_value).is_not(None), ) if request.scope_ids is not None: query = query.where(col(BountyScope.id).in_(request.scope_ids)) if not request.include_out_of_scope: query = query.where(BountyScope.scope_state == ScopeState.IN_SCOPE.value) rows = (await self.session.execute(query)).scalars().all() unreachable = select(BountyScope).where( BountyScope.program_id == program.id, col(BountyScope.target_value).is_(None), ) if request.scope_ids is not None: unreachable = unreachable.where(col(BountyScope.id).in_(request.scope_ids)) if not request.include_out_of_scope: unreachable = unreachable.where( BountyScope.scope_state == ScopeState.IN_SCOPE.value ) skipped = sorted( { s.asset_identifier for s in (await self.session.execute(unreachable)).scalars() } ) values = sorted({s.target_value for s in rows if s.target_value}) if not values: return BountyImportResult(created=[], existing=[], skipped=skipped) before = await self.session.execute( select(Target.target_value).where( Target.project_id == request.project_id, col(Target.target_value).in_(values), ) ) already = set(before.scalars().all()) targets = TargetService(self.session) rows = await targets.ensure_targets(values, request.project_id, user_id) organization = None if request.group_by_program: organization = await self._organization_for( program, request.project_id, user_id, request.organization_name ) await self._attach(rows, organization) tags = await self._tags_for(request, program, user_id) await self._attach_tags(rows, tags) await self.session.commit() return BountyImportResult( created=[v for v in values if v not in already], existing=sorted(already), skipped=skipped, organization=( OrganizationSummary( id=organization.id, name=organization.name, slug=organization.slug ) if organization else None ), tags=[ TagSummary(id=t.id, name=t.name, slug=t.slug, color=t.color) for t in tags ], ) async def _tags_for( self, request: BountyImportRequest, program: BountyProgram, user_id ) -> list[Tag]: """The project's tags for these names, created on first use.""" spec = PLATFORMS_BY_KEY.get(program.platform) wanted: dict[str, str] = {} for raw in request.tags[:MAX_TAGS_PER_IMPORT]: candidate = (raw or "").strip()[:MAX_TAG_NAME] if not candidate: continue wanted.setdefault(candidate.lower(), DEFAULT_TAG_COLOR) if spec and spec.tag in wanted: wanted[spec.tag] = spec.tag_color if not wanted: return [] found = await self.session.execute( select(Tag).where( Tag.project_id == request.project_id, col(Tag.name).in_(list(wanted)) ) ) tags = {t.name: t for t in found.scalars().all()} for name, color in wanted.items(): if name in tags: continue tag = Tag( name=name, color=color, project_id=request.project_id, created_by=user_id, ) try: async with self.session.begin_nested(): await add_with_unique_slug( self.session, tag, name, project_id=request.project_id ) except IntegrityError: again = await self.session.execute( select(Tag).where( Tag.project_id == request.project_id, Tag.name == name ) ) tags[name] = again.scalar_one() continue tags[name] = tag return [tags[name] for name in wanted if name in tags] async def _attach_tags(self, targets: list[Target], tags: list[Tag]) -> None: """A tag already on a target must not fail the import.""" if not targets or not tags: return await self.session.execute( pg_insert(TargetTag) .values( [{"target_id": t.id, "tag_id": tag.id} for t in targets for tag in tags] ) .on_conflict_do_nothing() ) async def _organization_for( self, program: BountyProgram, project_id: UUID, user_id, override: str | None = None, ) -> Organization: """The project's organization for this program, created on first import.""" chosen = ((override or "").strip() or program.name)[:MAX_ORG_NAME] try: name = clean_name(chosen, max_len=MAX_ORG_NAME).lower() except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) ) from exc found = await self.session.execute( select(Organization).where( Organization.project_id == project_id, Organization.name == name ) ) existing = found.scalar_one_or_none() if existing: return existing organization = Organization( name=name, description=f"{_platform_label(program.platform)} program @{program.handle}"[ :MAX_ORG_DESCRIPTION ], project_id=project_id, created_by=user_id, ) try: async with self.session.begin_nested(): await add_with_unique_slug( self.session, organization, name, project_id=project_id ) except IntegrityError: again = await self.session.execute( select(Organization).where( Organization.project_id == project_id, Organization.name == name ) ) return again.scalar_one() return organization async def _attach(self, targets: list[Target], organization: Organization) -> None: """A target already under the organization must not fail the import.""" if not targets: return await self.session.execute( pg_insert(TargetOrganization) .values( [ {"target_id": t.id, "organization_id": organization.id} for t in targets ] ) .on_conflict_do_nothing() ) @staticmethod def require_platform(platform: str) -> str: if platform not in PLATFORMS_BY_KEY: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Unknown platform: {platform}", ) return platform