Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
REDDIT_USER_AGENT=newsletter-maker/0.1

# Used to encrypt project-scoped Bluesky app passwords stored in the database.
# Set this to a stable secret in each environment.
BLUESKY_CREDENTIALS_ENCRYPTION_KEY=

# Outbound mail provider. Use Resend or Amazon SES.
EMAIL_BACKEND=anymail.backends.resend.EmailBackend
DEFAULT_FROM_EMAIL=onboarding@resend.dev
Expand Down
132 changes: 132 additions & 0 deletions core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import json

from django import forms
from django.contrib import admin, messages
from django.db.models import Avg
from django.utils import timezone
Expand All @@ -16,6 +17,7 @@
from unfold.admin import ModelAdmin

from core.models import (
BlueskyCredentials,
Content,
Entity,
IngestionRun,
Expand All @@ -29,6 +31,42 @@
from core.plugins import get_plugin_for_source_config, validate_plugin_config


class BlueskyCredentialsAdminForm(forms.ModelForm):
"""Admin form that accepts a plaintext Bluesky app credential input."""

credential_input = forms.CharField(
required=False,
strip=False,
widget=forms.PasswordInput(render_value=False),
help_text="Leave blank to keep the existing stored credential.",
label="Bluesky app credential",
)

class Meta:
model = BlueskyCredentials
fields = ["project", "handle", "pds_url", "is_active"]

def clean(self):
"""Require a credential when creating the record for the first time."""

cleaned_data = super().clean()
credential_input = cleaned_data.get("credential_input", "")
if not self.instance.has_stored_credential() and not credential_input:
self.add_error("credential_input", "A Bluesky app credential is required.")
return cleaned_data

def save(self, commit=True):
"""Encrypt a new credential value before saving the model instance."""

instance = super().save(commit=False)
credential_input = self.cleaned_data.get("credential_input", "")
if credential_input:
instance.set_stored_credential(credential_input)
if commit:
instance.save()
return instance


@admin.register(Project)
class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
"""Admin configuration for top-level project workspaces."""
Expand All @@ -49,6 +87,100 @@ class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
list_editable = ("content_retention_days",)


@admin.register(BlueskyCredentials)
class BlueskyCredentialsAdmin(ModelAdmin):
"""Admin view for project-scoped Bluesky authentication settings."""

form = BlueskyCredentialsAdminForm
actions = ["verify_selected_credentials"]
list_display = (
"project",
"handle",
"display_pds_host",
"has_stored_credential",
"is_active",
"last_verified_at",
)
list_filter = ("is_active", ("project", admin.RelatedOnlyFieldListFilter))
search_fields = ("project__name", "handle", "pds_url")
autocomplete_fields = ("project",)
readonly_fields = (
"has_stored_credential",
"last_verified_at",
"last_error",
"created_at",
"updated_at",
)
fieldsets = (
(
"Account",
{"fields": ("project", "handle", "credential_input", "is_active")},
),
(
"PDS Override",
{
"fields": ("pds_url",),
"description": "Leave blank to use the default Bluesky-hosted account flow.",
},
),
(
"Verification",
{
"fields": (
"has_stored_credential",
"last_verified_at",
"last_error",
"created_at",
"updated_at",
)
},
),
)

@admin.display(description="PDS")
def display_pds_host(self, obj):
"""Show whether the credentials use the hosted default or a custom PDS."""

return obj.pds_url or "Bluesky hosted default"

@admin.display(boolean=True, description="Stored Credential")
def has_stored_credential(self, obj):
"""Return whether an encrypted Bluesky credential has been configured."""

return obj.has_stored_credential()

@admin.action(description="Verify Selected Credentials")
def verify_selected_credentials(self, request, queryset):
"""Authenticate the selected Bluesky accounts and report the outcome."""

from core.plugins.bluesky import BlueskySourcePlugin

verified_credentials = []
failed_credentials = []

for credentials in queryset.select_related("project"):
try:
BlueskySourcePlugin.verify_credentials(credentials)
except Exception as exc:
failed_credentials.append(f"{credentials}: {exc}")
else:
verified_credentials.append(str(credentials))

if verified_credentials:
self.message_user(
request,
f"Credential verification passed for {len(verified_credentials)} account(s).",
messages.SUCCESS,
)

if failed_credentials:
self.message_user(
request,
"Credential verification failed for: " + "; ".join(failed_credentials),
messages.ERROR,
)


@admin.register(ProjectConfig)
class ProjectConfigAdmin(admin.ModelAdmin):
"""Admin configuration for per-project scoring settings."""
Expand Down
103 changes: 103 additions & 0 deletions core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
generated schema consistent across similar viewsets.
"""

import logging
from typing import Any

from drf_spectacular.utils import (
Expand All @@ -21,6 +22,7 @@
from rest_framework.response import Response

from core.models import (
BlueskyCredentials,
Content,
Entity,
IngestionRun,
Expand Down Expand Up @@ -48,6 +50,8 @@
SUMMARIZATION_SKILL_NAME = "summarization"
RELATED_CONTENT_SKILL_NAME = "find_related"

logger = logging.getLogger(__name__)

PROJECT_ID_PARAMETER = OpenApiParameter(
name="project_id",
type=int,
Expand Down Expand Up @@ -115,6 +119,20 @@
request_only=True,
)

SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE = OpenApiExample(
"Create Bluesky Source Request",
value={
"plugin_name": "bluesky",
"config": {
"author_handle": "alice.bsky.social",
"include_replies": False,
"max_posts_per_fetch": 100,
},
"is_active": True,
},
request_only=True,
)

SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
"Source Configuration Response",
value={
Expand Down Expand Up @@ -228,6 +246,16 @@
examples=[AUTHENTICATION_REQUIRED_EXAMPLE],
)

BLUESKY_CREDENTIALS_VERIFY_RESPONSE = inline_serializer(
name="BlueskyCredentialsVerifyResponse",
fields={
"status": serializers.CharField(),
"handle": serializers.CharField(),
"last_verified_at": serializers.DateTimeField(allow_null=True),
"last_error": serializers.CharField(allow_blank=True),
},
)


def build_success_response(
response, description: str, examples: list[OpenApiExample] | None = None
Expand Down Expand Up @@ -554,6 +582,80 @@ def get_queryset(self):

return self.queryset.filter(group__user=self.request.user).distinct()

@extend_schema(
summary="Verify Bluesky credentials",
description=(
"Verify the selected project's stored Bluesky credentials by authenticating "
"the account and checking the current session."
),
tags=["Ingestion"],
request=None,
responses={
200: build_success_response(
BLUESKY_CREDENTIALS_VERIFY_RESPONSE,
"The project's Bluesky credentials were verified successfully.",
),
400: OpenApiResponse(
response=inline_serializer(
name="BlueskyCredentialsVerifyErrorResponse",
fields={
"type": serializers.CharField(),
"errors": inline_serializer(
name="BlueskyCredentialsVerifyError",
fields={
"code": serializers.CharField(),
"detail": serializers.CharField(),
"attr": serializers.CharField(allow_null=True),
},
many=True,
),
},
),
description="The project is missing Bluesky credentials or verification failed.",
),
403: AUTHENTICATION_REQUIRED_RESPONSE,
},
)
@action(detail=True, methods=["post"], url_path="verify-bluesky-credentials")
def verify_bluesky_credentials(self, request, *args, **kwargs):
"""Verify the Bluesky credentials stored for the selected project."""

from core.plugins.bluesky import BlueskySourcePlugin

project = self.get_object()
try:
credentials = project.bluesky_credentials
except BlueskyCredentials.DoesNotExist as exc:
raise serializers.ValidationError(
{"bluesky_credentials": "No Bluesky credentials are configured for this project."}
) from exc

try:
BlueskySourcePlugin.verify_credentials(credentials)
except Exception as exc:
logger.exception(
"Bluesky credential verification failed for project id=%s",
project.id,
)
raise serializers.ValidationError(
{
"bluesky_credentials": (
"Credential verification failed. Please re-check the credentials "
"and try again."
)
}
) from exc

credentials.refresh_from_db()
return Response(
{
"status": "verified",
"handle": credentials.handle,
"last_verified_at": credentials.last_verified_at,
"last_error": "",
}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
)


@document_project_owned_viewset(
resource_plural="project configurations",
Expand Down Expand Up @@ -741,6 +843,7 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
create_examples=[
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE,
SOURCE_CONFIG_REDDIT_REQUEST_EXAMPLE,
SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE,
SOURCE_CONFIG_RESPONSE_EXAMPLE,
],
create_response_examples=[SOURCE_CONFIG_RESPONSE_EXAMPLE],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 6.0.4 on 2026-04-28 23:38

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0002_newsletter_intake"),
]

operations = [
migrations.RenameIndex(
model_name="newsletterintake",
new_name="core_newsle_project_eee7a4_idx",
old_name="core_newsle_project_2c63fb_idx",
),
migrations.AlterField(
model_name="sourceconfig",
name="plugin_name",
field=models.CharField(
choices=[("rss", "RSS"), ("reddit", "Reddit"), ("bluesky", "Bluesky")],
max_length=64,
),
),
]
Loading
Loading