Split dashboard.go (3,528 lines) into: - routes.go: HTTP route registration - api_domains.go: Domain API handlers - api_feeds.go: Feed API handlers - api_publish.go: Publishing API handlers - api_search.go: Search API handlers - templates.go: HTML templates - dashboard.go: Stats functions only (235 lines) Split publisher.go (1,502 lines) into: - pds_auth.go: Authentication and account management - pds_records.go: Record operations (upload, update, delete) - handle.go: Handle derivation from feed URLs - image.go: Image processing and favicon fetching - publisher.go: Core types and PublishItem (439 lines) Split feed.go (1,137 lines) into: - item.go: Item struct and DB operations - feed_check.go: Feed checking and processing - feed.go: Feed struct and DB operations (565 lines) Also includes domain import batch size increase (1k -> 100k). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
188 lines
4.4 KiB
Go
188 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// CreateSession authenticates with the PDS and returns a session
|
|
func (p *Publisher) CreateSession(handle, password string) (*PDSSession, error) {
|
|
payload := map[string]string{
|
|
"identifier": handle,
|
|
"password": password,
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Post(
|
|
p.pdsHost+"/xrpc/com.atproto.server.createSession",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("auth failed: %s - %s", resp.Status, string(respBody))
|
|
}
|
|
|
|
var session PDSSession
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// CreateAccount creates a new account on the PDS
|
|
// Requires an invite code if the PDS has invites enabled
|
|
func (p *Publisher) CreateAccount(handle, email, password, inviteCode string) (*PDSSession, error) {
|
|
payload := map[string]interface{}{
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password,
|
|
}
|
|
if inviteCode != "" {
|
|
payload["inviteCode"] = inviteCode
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Post(
|
|
p.pdsHost+"/xrpc/com.atproto.server.createAccount",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("create account failed: %s - %s", resp.Status, string(respBody))
|
|
}
|
|
|
|
var session PDSSession
|
|
if err := json.Unmarshal(respBody, &session); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// CreateInviteCode creates an invite code using PDS admin password (Basic Auth)
|
|
func (p *Publisher) CreateInviteCode(adminPassword string, useCount int) (string, error) {
|
|
payload := map[string]interface{}{
|
|
"useCount": useCount,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.server.createInviteCode", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// PDS admin APIs use Basic Auth with "admin" as username
|
|
req.SetBasicAuth("admin", adminPassword)
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("create invite failed: %s - %s", resp.Status, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return result.Code, nil
|
|
}
|
|
|
|
// FollowAccount creates a follow record from the authenticated session to the target DID
|
|
func (p *Publisher) FollowAccount(session *PDSSession, targetDID string) error {
|
|
// Create follow record
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
record := map[string]interface{}{
|
|
"$type": "app.bsky.graph.follow",
|
|
"subject": targetDID,
|
|
"createdAt": now,
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"repo": session.DID,
|
|
"collection": "app.bsky.graph.follow",
|
|
"record": record,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.createRecord", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("follow failed: %s - %s", resp.Status, string(respBody))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FollowAsDirectory logs in as the directory account and follows the target DID
|
|
func (p *Publisher) FollowAsDirectory(targetDID string) error {
|
|
dirHandle := os.Getenv("DIRECTORY_HANDLE")
|
|
dirPassword := os.Getenv("DIRECTORY_PASSWORD")
|
|
|
|
if dirHandle == "" || dirPassword == "" {
|
|
// Silently skip if directory account not configured
|
|
return nil
|
|
}
|
|
|
|
session, err := p.CreateSession(dirHandle, dirPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("directory login failed: %w", err)
|
|
}
|
|
|
|
return p.FollowAccount(session, targetDID)
|
|
}
|