Add AT Protocol OAuth 2.0 authentication for dashboard
- Implement full OAuth 2.0 with PKCE using haileyok/atproto-oauth-golang - Backend For Frontend (BFF) pattern: tokens stored server-side only - AES-256-GCM encrypted session cookies - Auto token refresh when near expiry - Restrict access to allowed handles (1440.news, wehrv.bsky.social) - Add genkey utility for generating OAuth configuration - Generic error messages to prevent handle enumeration - Server-side logging of failed login attempts for security monitoring New files: - oauth.go: OAuth client wrapper and DID/handle resolution - oauth_session.go: Session management with encrypted cookies - oauth_middleware.go: RequireAuth middleware for route protection - oauth_handlers.go: Login, callback, logout, metadata endpoints - cmd/genkey/main.go: Generate OAuth secrets and JWK keypair - oauth.env.example: Configuration template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ feeds/
|
|||||||
feeds.db/
|
feeds.db/
|
||||||
1440.db
|
1440.db
|
||||||
pds.env
|
pds.env
|
||||||
|
oauth.env
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
> **Note:** Always run applications in containers via `docker compose up -d --build` when possible. This ensures proper networking between services (database, traefik, etc.) and matches the production environment.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -67,6 +69,11 @@ The application runs seven independent goroutine loops:
|
|||||||
| `db.go` | PostgreSQL schema (domains, feeds, items tables with tsvector FTS) |
|
| `db.go` | PostgreSQL schema (domains, feeds, items tables with tsvector FTS) |
|
||||||
| `dashboard.go` | HTTP server, JSON APIs, HTML template |
|
| `dashboard.go` | HTTP server, JSON APIs, HTML template |
|
||||||
| `publisher.go` | AT Protocol PDS integration for posting items |
|
| `publisher.go` | AT Protocol PDS integration for posting items |
|
||||||
|
| `oauth.go` | OAuth 2.0 client wrapper for AT Protocol authentication |
|
||||||
|
| `oauth_session.go` | Session management with AES-256-GCM encrypted cookies |
|
||||||
|
| `oauth_middleware.go` | RequireAuth middleware for protecting routes |
|
||||||
|
| `oauth_handlers.go` | OAuth HTTP endpoints (login, callback, logout, metadata) |
|
||||||
|
| `routes.go` | HTTP route registration with auth middleware |
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
@@ -128,3 +135,50 @@ PDS configuration in `pds.env`:
|
|||||||
PDS_HOST=https://pds.1440.news
|
PDS_HOST=https://pds.1440.news
|
||||||
PDS_ADMIN_PASSWORD=<admin_password>
|
PDS_ADMIN_PASSWORD=<admin_password>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Dashboard Authentication
|
||||||
|
|
||||||
|
The dashboard is protected by AT Protocol OAuth 2.0. Only the `@1440.news` handle can access it.
|
||||||
|
|
||||||
|
### OAuth Setup
|
||||||
|
|
||||||
|
1. Generate configuration:
|
||||||
|
```bash
|
||||||
|
go run ./cmd/genkey
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `oauth.env` with the generated values:
|
||||||
|
```
|
||||||
|
OAUTH_COOKIE_SECRET=<generated_hex_string>
|
||||||
|
OAUTH_PRIVATE_JWK=<generated_jwk_json>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Optionally set the base URL (defaults to https://app.1440.news):
|
||||||
|
```
|
||||||
|
OAUTH_BASE_URL=https://app.1440.news
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Flow
|
||||||
|
|
||||||
|
1. User navigates to `/dashboard` -> redirected to `/auth/login`
|
||||||
|
2. User enters their Bluesky handle
|
||||||
|
3. User is redirected to Bluesky authorization
|
||||||
|
4. After approval, callback verifies handle is `1440.news`
|
||||||
|
5. Session cookie is set, user redirected to dashboard
|
||||||
|
|
||||||
|
### OAuth Endpoints
|
||||||
|
|
||||||
|
- `/.well-known/oauth-client-metadata` - Client metadata (public)
|
||||||
|
- `/.well-known/jwks.json` - Public JWK set (public)
|
||||||
|
- `/auth/login` - Login page / initiates OAuth flow
|
||||||
|
- `/auth/callback` - OAuth callback handler
|
||||||
|
- `/auth/logout` - Clears session
|
||||||
|
- `/auth/session` - Returns current session info (JSON)
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Tokens are stored server-side only (BFF pattern)
|
||||||
|
- Browser only receives encrypted session cookie (AES-256-GCM)
|
||||||
|
- Access restricted to single handle (`1440.news`)
|
||||||
|
- Sessions expire after 24 hours
|
||||||
|
- Automatic token refresh when within 5 minutes of expiry
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// genkey generates an ES256 JWK keypair for OAuth client authentication
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Generating OAuth configuration...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Generate cookie secret
|
||||||
|
cookieSecret := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(cookieSecret); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error generating cookie secret: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ES256 JWK
|
||||||
|
key, err := helpers.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error generating JWK: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal JWK to JSON
|
||||||
|
keyJSON, err := json.Marshal(key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error marshaling JWK: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Add these values to your oauth.env file:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("OAUTH_COOKIE_SECRET=%s\n", hex.EncodeToString(cookieSecret))
|
||||||
|
fmt.Printf("OAUTH_PRIVATE_JWK=%s\n", string(keyJSON))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Keep these values secret!")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
env_file:
|
env_file:
|
||||||
- pds.env
|
- pds.env
|
||||||
|
- oauth.env
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: atproto-postgres
|
DB_HOST: atproto-postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# OAuth Configuration for 1440.news Dashboard
|
||||||
|
# Copy this file to oauth.env and fill in the values
|
||||||
|
|
||||||
|
# Cookie encryption secret (32 bytes / 64 hex characters)
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
OAUTH_COOKIE_SECRET=
|
||||||
|
|
||||||
|
# ES256 private JWK for client authentication
|
||||||
|
# Generate with the command below, then paste the JSON output here (on one line)
|
||||||
|
#
|
||||||
|
# To generate a key using Go:
|
||||||
|
# go run ./cmd/genkey
|
||||||
|
#
|
||||||
|
# Or use openssl + jq:
|
||||||
|
# openssl ecparam -name prime256v1 -genkey -noout | openssl ec -text -noout 2>/dev/null | \
|
||||||
|
# awk '/priv:/{p=1} p{print}' | head -5 | tr -d ' \n:' | xxd -r -p | base64
|
||||||
|
#
|
||||||
|
# The JWK should look like:
|
||||||
|
# {"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"..."}
|
||||||
|
OAUTH_PRIVATE_JWK=
|
||||||
|
|
||||||
|
# Optional: Override the base URL for OAuth redirects
|
||||||
|
# Default: https://app.1440.news (production) or http://localhost:4321 (local)
|
||||||
|
# OAUTH_BASE_URL=https://app.1440.news
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
oauth "github.com/haileyok/atproto-oauth-golang"
|
||||||
|
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthManager handles OAuth 2.0 authentication for the dashboard
|
||||||
|
type OAuthManager struct {
|
||||||
|
client *oauth.Client
|
||||||
|
clientID string
|
||||||
|
redirectURI string
|
||||||
|
privateJWK jwk.Key
|
||||||
|
publicJWK jwk.Key
|
||||||
|
sessions *SessionStore
|
||||||
|
cookieSecret []byte
|
||||||
|
allowedScope string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthConfig holds configuration for the OAuth manager
|
||||||
|
type OAuthConfig struct {
|
||||||
|
ClientID string // URL to client metadata (e.g., https://app.1440.news/.well-known/oauth-client-metadata)
|
||||||
|
RedirectURI string // OAuth callback URL (e.g., https://app.1440.news/auth/callback)
|
||||||
|
CookieSecret string // 32-byte hex string for AES-256-GCM encryption
|
||||||
|
PrivateJWK string // ES256 private key as JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthManager creates a new OAuth manager
|
||||||
|
func NewOAuthManager(cfg OAuthConfig) (*OAuthManager, error) {
|
||||||
|
// Parse cookie secret (must be 32 bytes for AES-256)
|
||||||
|
cookieSecret, err := parseHexSecret(cfg.CookieSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid cookie secret: %v", err)
|
||||||
|
}
|
||||||
|
if len(cookieSecret) != 32 {
|
||||||
|
return nil, fmt.Errorf("cookie secret must be 32 bytes, got %d", len(cookieSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse private JWK
|
||||||
|
privateJWK, err := helpers.ParseJWKFromBytes([]byte(cfg.PrivateJWK))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid private JWK: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract public key
|
||||||
|
publicJWK, err := privateJWK.PublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract public key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with longer timeout
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create OAuth client
|
||||||
|
client, err := oauth.NewClient(oauth.ClientArgs{
|
||||||
|
Http: httpClient,
|
||||||
|
ClientJwk: privateJWK,
|
||||||
|
ClientId: cfg.ClientID,
|
||||||
|
RedirectUri: cfg.RedirectURI,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create OAuth client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OAuthManager{
|
||||||
|
client: client,
|
||||||
|
clientID: cfg.ClientID,
|
||||||
|
redirectURI: cfg.RedirectURI,
|
||||||
|
privateJWK: privateJWK,
|
||||||
|
publicJWK: publicJWK,
|
||||||
|
sessions: NewSessionStore(),
|
||||||
|
cookieSecret: cookieSecret,
|
||||||
|
allowedScope: "atproto",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOAuthConfig loads OAuth configuration from environment or oauth.env file
|
||||||
|
func LoadOAuthConfig(baseURL string) (*OAuthConfig, error) {
|
||||||
|
cfg := &OAuthConfig{
|
||||||
|
ClientID: baseURL + "/.well-known/oauth-client-metadata",
|
||||||
|
RedirectURI: baseURL + "/auth/callback",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try environment variables first
|
||||||
|
cfg.CookieSecret = os.Getenv("OAUTH_COOKIE_SECRET")
|
||||||
|
cfg.PrivateJWK = os.Getenv("OAUTH_PRIVATE_JWK")
|
||||||
|
|
||||||
|
// Fall back to oauth.env file
|
||||||
|
if cfg.CookieSecret == "" || cfg.PrivateJWK == "" {
|
||||||
|
if data, err := os.ReadFile("oauth.env"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "#") || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
switch key {
|
||||||
|
case "OAUTH_COOKIE_SECRET":
|
||||||
|
cfg.CookieSecret = value
|
||||||
|
case "OAUTH_PRIVATE_JWK":
|
||||||
|
cfg.PrivateJWK = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if cfg.CookieSecret == "" {
|
||||||
|
return nil, fmt.Errorf("OAUTH_COOKIE_SECRET not configured")
|
||||||
|
}
|
||||||
|
if cfg.PrivateJWK == "" {
|
||||||
|
return nil, fmt.Errorf("OAUTH_PRIVATE_JWK not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHexSecret converts a hex string to bytes
|
||||||
|
func parseHexSecret(hex string) ([]byte, error) {
|
||||||
|
if len(hex)%2 != 0 {
|
||||||
|
return nil, fmt.Errorf("hex string must have even length")
|
||||||
|
}
|
||||||
|
b := make([]byte, len(hex)/2)
|
||||||
|
for i := 0; i < len(hex); i += 2 {
|
||||||
|
var val byte
|
||||||
|
for j := 0; j < 2; j++ {
|
||||||
|
c := hex[i+j]
|
||||||
|
switch {
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
val = val*16 + (c - '0')
|
||||||
|
case c >= 'a' && c <= 'f':
|
||||||
|
val = val*16 + (c - 'a' + 10)
|
||||||
|
case c >= 'A' && c <= 'F':
|
||||||
|
val = val*16 + (c - 'A' + 10)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid hex character: %c", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b[i/2] = val
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHandle resolves a Bluesky handle to a DID
|
||||||
|
func resolveHandle(ctx context.Context, handle string) (string, error) {
|
||||||
|
// Normalize handle (remove @ prefix and whitespace)
|
||||||
|
handle = strings.TrimSpace(handle)
|
||||||
|
handle = strings.TrimPrefix(handle, "@")
|
||||||
|
handle = strings.ToLower(handle)
|
||||||
|
|
||||||
|
// Try DNS-based resolution first
|
||||||
|
url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", neturl.QueryEscape(handle))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("resolve handle failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.DID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDIDToHandle resolves a DID to the current handle
|
||||||
|
func resolveDIDToHandle(ctx context.Context, did string) (string, error) {
|
||||||
|
// Fetch DID document
|
||||||
|
var docURL string
|
||||||
|
if strings.HasPrefix(did, "did:plc:") {
|
||||||
|
docURL = fmt.Sprintf("https://plc.directory/%s", did)
|
||||||
|
} else if strings.HasPrefix(did, "did:web:") {
|
||||||
|
domain := strings.TrimPrefix(did, "did:web:")
|
||||||
|
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unsupported DID method: %s", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
AlsoKnownAs []string `json:"alsoKnownAs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the at:// handle
|
||||||
|
for _, aka := range doc.AlsoKnownAs {
|
||||||
|
if strings.HasPrefix(aka, "at://") {
|
||||||
|
return strings.TrimPrefix(aka, "at://"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no handle found for DID %s", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDIDToService gets the PDS service URL from a DID
|
||||||
|
func resolveDIDToService(ctx context.Context, did string) (string, error) {
|
||||||
|
var docURL string
|
||||||
|
if strings.HasPrefix(did, "did:plc:") {
|
||||||
|
docURL = fmt.Sprintf("https://plc.directory/%s", did)
|
||||||
|
} else if strings.HasPrefix(did, "did:web:") {
|
||||||
|
domain := strings.TrimPrefix(did, "did:web:")
|
||||||
|
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unsupported DID method: %s", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
Service []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||||
|
} `json:"service"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the atproto_pds service
|
||||||
|
for _, svc := range doc.Service {
|
||||||
|
if svc.Type == "AtprotoPersonalDataServer" || svc.ID == "#atproto_pds" {
|
||||||
|
return svc.ServiceEndpoint, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no PDS service found for DID %s", did)
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedHandles = map[string]bool{
|
||||||
|
"1440.news": true,
|
||||||
|
"wehrv.bsky.social": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleClientMetadata serves the OAuth client metadata
|
||||||
|
func (m *OAuthManager) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get the JWKS URI from the same host
|
||||||
|
scheme := "https"
|
||||||
|
if r.TLS == nil && (r.Host == "localhost" || r.Host == "127.0.0.1" || r.Host == "app.1440.localhost:4321") {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
baseURL := scheme + "://" + r.Host
|
||||||
|
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"client_id": m.clientID,
|
||||||
|
"client_name": "1440.news Dashboard",
|
||||||
|
"client_uri": baseURL,
|
||||||
|
"redirect_uris": []string{m.redirectURI},
|
||||||
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||||
|
"response_types": []string{"code"},
|
||||||
|
"scope": "atproto",
|
||||||
|
"token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"token_endpoint_auth_signing_alg": "ES256",
|
||||||
|
"dpop_bound_access_tokens": true,
|
||||||
|
"jwks_uri": baseURL + "/.well-known/jwks.json",
|
||||||
|
"application_type": "web",
|
||||||
|
"subject_type": "public",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleJWKS serves the public JWK set
|
||||||
|
func (m *OAuthManager) HandleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jwks := helpers.CreateJwksResponseObject(m.publicJWK)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(jwks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin serves the login page or initiates OAuth flow
|
||||||
|
func (m *OAuthManager) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if already logged in
|
||||||
|
if session := m.GetSessionFromCookie(r); session != nil {
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If handle is provided, start OAuth flow
|
||||||
|
handle := r.URL.Query().Get("handle")
|
||||||
|
if handle != "" {
|
||||||
|
m.startOAuthFlow(w, r, handle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve login page
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl := template.Must(template.New("login").Parse(loginPageHTML))
|
||||||
|
tmpl.Execute(w, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOAuthFlow initiates the OAuth flow for a given handle
|
||||||
|
func (m *OAuthManager) startOAuthFlow(w http.ResponseWriter, r *http.Request, handle string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fmt.Printf("OAuth: starting flow for handle: %s\n", handle)
|
||||||
|
|
||||||
|
// Resolve handle to DID
|
||||||
|
did, err := resolveHandle(ctx, handle)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("OAuth: resolved DID: %s\n", did)
|
||||||
|
|
||||||
|
// Resolve DID to PDS service URL
|
||||||
|
pdsURL, err := resolveDIDToService(ctx, did)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to resolve PDS: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("OAuth: PDS URL: %s\n", pdsURL)
|
||||||
|
|
||||||
|
// Get auth server from PDS
|
||||||
|
authServerURL, err := m.client.ResolvePdsAuthServer(ctx, pdsURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to resolve auth server: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("OAuth: auth server: %s\n", authServerURL)
|
||||||
|
|
||||||
|
// Fetch auth server metadata
|
||||||
|
authMeta, err := m.client.FetchAuthServerMetadata(ctx, authServerURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to fetch auth metadata: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("OAuth: auth endpoint: %s\n", authMeta.AuthorizationEndpoint)
|
||||||
|
|
||||||
|
// Generate DPoP private key for this auth flow
|
||||||
|
dpopKey, err := helpers.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate DPoP key: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dpopKeyBytes, err := json.Marshal(dpopKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to marshal DPoP key: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send PAR (Pushed Authorization Request)
|
||||||
|
fmt.Printf("OAuth: sending PAR to %s\n", authServerURL)
|
||||||
|
parResp, err := m.client.SendParAuthRequest(
|
||||||
|
ctx,
|
||||||
|
authServerURL,
|
||||||
|
authMeta,
|
||||||
|
handle,
|
||||||
|
m.allowedScope,
|
||||||
|
dpopKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("OAuth: PAR failed: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("PAR request failed: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("OAuth: PAR success, request_uri: %s\n", parResp.RequestUri)
|
||||||
|
|
||||||
|
// Save pending auth state
|
||||||
|
pending := &PendingAuth{
|
||||||
|
State: parResp.State,
|
||||||
|
PkceVerifier: parResp.PkceVerifier,
|
||||||
|
DpopPrivateJWK: string(dpopKeyBytes),
|
||||||
|
DpopNonce: parResp.DpopAuthserverNonce,
|
||||||
|
DID: did,
|
||||||
|
PdsURL: pdsURL,
|
||||||
|
AuthserverIss: authMeta.Issuer,
|
||||||
|
}
|
||||||
|
m.sessions.SavePending(parResp.State, pending)
|
||||||
|
|
||||||
|
// Build authorization URL
|
||||||
|
authURL, err := url.Parse(authMeta.AuthorizationEndpoint)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid auth endpoint: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := authURL.Query()
|
||||||
|
q.Set("client_id", m.clientID)
|
||||||
|
q.Set("request_uri", parResp.RequestUri)
|
||||||
|
authURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
fmt.Printf("OAuth: redirecting to: %s\n", authURL.String())
|
||||||
|
|
||||||
|
// Use JavaScript redirect to preserve browser security headers
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Redirecting...</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting to authorization server...</p>
|
||||||
|
<script>window.location.href = %q;</script>
|
||||||
|
<noscript><a href="%s">Click here to continue</a></noscript>
|
||||||
|
</body>
|
||||||
|
</html>`, authURL.String(), authURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCallback handles the OAuth callback
|
||||||
|
func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get callback parameters
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
iss := r.URL.Query().Get("iss")
|
||||||
|
errorParam := r.URL.Query().Get("error")
|
||||||
|
errorDesc := r.URL.Query().Get("error_description")
|
||||||
|
|
||||||
|
// Check for errors from auth server
|
||||||
|
if errorParam != "" {
|
||||||
|
http.Error(w, fmt.Sprintf("Authorization error: %s - %s", errorParam, errorDesc), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" || state == "" {
|
||||||
|
http.Error(w, "Missing code or state parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve pending auth state
|
||||||
|
pending := m.sessions.GetPending(state)
|
||||||
|
if pending == nil {
|
||||||
|
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issuer matches
|
||||||
|
if iss != "" && iss != pending.AuthserverIss {
|
||||||
|
http.Error(w, "Issuer mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse DPoP private key
|
||||||
|
dpopKey, err := helpers.ParseJWKFromBytes([]byte(pending.DpopPrivateJWK))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to parse DPoP key: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
tokenResp, err := m.client.InitialTokenRequest(
|
||||||
|
ctx,
|
||||||
|
code,
|
||||||
|
pending.AuthserverIss,
|
||||||
|
pending.PkceVerifier,
|
||||||
|
pending.DpopNonce,
|
||||||
|
dpopKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify scope
|
||||||
|
if tokenResp.Scope != m.allowedScope {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid scope: expected %s, got %s", m.allowedScope, tokenResp.Scope), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve DID to handle
|
||||||
|
handle, err := resolveDIDToHandle(ctx, tokenResp.Sub)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Verify user is allowed
|
||||||
|
if !allowedHandles[handle] {
|
||||||
|
fmt.Printf("OAuth: access denied for handle: %s\n", handle)
|
||||||
|
http.Error(w, "Access denied.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session, err := m.sessions.CreateSession(tokenResp.Sub, handle)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token info in session
|
||||||
|
session.AccessToken = tokenResp.AccessToken
|
||||||
|
session.RefreshToken = tokenResp.RefreshToken
|
||||||
|
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
session.DpopPrivateJWK = pending.DpopPrivateJWK
|
||||||
|
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
|
||||||
|
session.PdsURL = pending.PdsURL
|
||||||
|
session.AuthserverIss = pending.AuthserverIss
|
||||||
|
m.sessions.UpdateSession(session)
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
if err := m.SetSessionCookie(w, session.ID); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to set cookie: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout clears the session and redirects to login
|
||||||
|
func (m *OAuthManager) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get current session
|
||||||
|
session := m.GetSessionFromCookie(r)
|
||||||
|
if session != nil {
|
||||||
|
// Delete session from store
|
||||||
|
m.sessions.DeleteSession(session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cookie
|
||||||
|
m.ClearSessionCookie(w)
|
||||||
|
|
||||||
|
// Handle API vs browser request
|
||||||
|
if r.Method == http.MethodPost || isAPIRequest(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "logged out",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login for browser requests
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSessionInfo returns current session info (for API calls)
|
||||||
|
func (m *OAuthManager) HandleSessionInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := m.GetSessionFromCookie(r)
|
||||||
|
if session == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "not authenticated",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &SessionInfo{
|
||||||
|
DID: session.DID,
|
||||||
|
Handle: session.Handle,
|
||||||
|
ExpiresAt: session.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginPageHTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Sign In - 1440.news Dashboard</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 3em;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tagline {
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: #151515;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0af;
|
||||||
|
}
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: #0af;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.login-btn:hover {
|
||||||
|
background: #0cf;
|
||||||
|
}
|
||||||
|
.login-btn:disabled {
|
||||||
|
background: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #2a1515;
|
||||||
|
border: 1px solid #533;
|
||||||
|
color: #f88;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.bluesky-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">1440.news</div>
|
||||||
|
<p class="tagline">Dashboard Authentication</p>
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<h2>Sign In with Bluesky</h2>
|
||||||
|
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
|
||||||
|
<form id="loginForm" action="/auth/login" method="get">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="handle">Bluesky Handle</label>
|
||||||
|
<input type="text" id="handle" name="handle" placeholder="handle.bsky.social" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="login-btn" id="loginBtn">
|
||||||
|
<svg class="bluesky-icon" viewBox="0 0 568 501" fill="currentColor">
|
||||||
|
<path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 googling=383.039C286.251 378.892 284.991 374.834 284 371.019C283.009 374.834 281.749 378.892 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0533 296.954 15.7778 224.501C9.94533 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z"/>
|
||||||
|
</svg>
|
||||||
|
Sign In with Bluesky
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
||||||
|
const handle = document.getElementById('handle').value.trim();
|
||||||
|
if (!handle) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
document.getElementById('error').textContent = 'Please enter your handle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('loginBtn').disabled = true;
|
||||||
|
document.getElementById('loginBtn').textContent = 'Redirecting...';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAuth is middleware that protects routes requiring authentication
|
||||||
|
func (m *OAuthManager) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := m.GetSessionFromCookie(r)
|
||||||
|
if session == nil {
|
||||||
|
// Check if this is an API call (wants JSON response)
|
||||||
|
if isAPIRequest(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "unauthorized",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Redirect to login for browser requests
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token needs refresh (refresh when within 5 minutes of expiry)
|
||||||
|
if time.Until(session.TokenExpiry) < 5*time.Minute {
|
||||||
|
if err := m.refreshToken(r.Context(), session); err != nil {
|
||||||
|
// Token refresh failed - clear session and redirect to login
|
||||||
|
m.sessions.DeleteSession(session.ID)
|
||||||
|
m.ClearSessionCookie(w)
|
||||||
|
|
||||||
|
if isAPIRequest(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "session expired",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session to request context
|
||||||
|
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionContextKey is the context key for the OAuth session
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const sessionContextKey contextKey = "oauth_session"
|
||||||
|
|
||||||
|
// GetSession retrieves the session from request context
|
||||||
|
func GetSession(r *http.Request) *OAuthSession {
|
||||||
|
session, _ := r.Context().Value(sessionContextKey).(*OAuthSession)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAPIRequest checks if the request expects JSON response
|
||||||
|
func isAPIRequest(r *http.Request) bool {
|
||||||
|
// Check Accept header
|
||||||
|
accept := r.Header.Get("Accept")
|
||||||
|
if strings.Contains(accept, "application/json") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check URL path
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-Requested-With header (for AJAX)
|
||||||
|
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshToken refreshes the OAuth access token
|
||||||
|
func (m *OAuthManager) refreshToken(ctx context.Context, session *OAuthSession) error {
|
||||||
|
if session.RefreshToken == "" {
|
||||||
|
return nil // No refresh token available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the DPoP private key
|
||||||
|
dpopKey, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJWK))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the token
|
||||||
|
tokenResp, err := m.client.RefreshTokenRequest(
|
||||||
|
ctx,
|
||||||
|
session.RefreshToken,
|
||||||
|
session.AuthserverIss,
|
||||||
|
session.DpopAuthserverNonce,
|
||||||
|
dpopKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session with new tokens
|
||||||
|
session.AccessToken = tokenResp.AccessToken
|
||||||
|
session.RefreshToken = tokenResp.RefreshToken
|
||||||
|
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
|
||||||
|
|
||||||
|
// Save updated session
|
||||||
|
m.sessions.UpdateSession(session)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "1440_session"
|
||||||
|
sessionTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthSession stores the OAuth session state for a user
|
||||||
|
type OAuthSession struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DID string `json:"did"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
|
||||||
|
// OAuth tokens (stored server-side only)
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenExpiry time.Time `json:"token_expiry"`
|
||||||
|
|
||||||
|
// DPoP state
|
||||||
|
DpopPrivateJWK string `json:"dpop_private_jwk"`
|
||||||
|
DpopAuthserverNonce string `json:"dpop_authserver_nonce"`
|
||||||
|
DpopPdsNonce string `json:"dpop_pds_nonce"`
|
||||||
|
|
||||||
|
// Auth server info
|
||||||
|
PdsURL string `json:"pds_url"`
|
||||||
|
AuthserverIss string `json:"authserver_iss"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingAuth stores state during the OAuth flow (before callback)
|
||||||
|
type PendingAuth struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
PkceVerifier string `json:"pkce_verifier"`
|
||||||
|
DpopPrivateJWK string `json:"dpop_private_jwk"`
|
||||||
|
DpopNonce string `json:"dpop_nonce"`
|
||||||
|
DID string `json:"did"`
|
||||||
|
PdsURL string `json:"pds_url"`
|
||||||
|
AuthserverIss string `json:"authserver_iss"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore manages sessions in memory
|
||||||
|
type SessionStore struct {
|
||||||
|
sessions map[string]*OAuthSession
|
||||||
|
pending map[string]*PendingAuth // keyed by state
|
||||||
|
mu sync.RWMutex
|
||||||
|
cleanupOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionStore creates a new session store
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
s := &SessionStore{
|
||||||
|
sessions: make(map[string]*OAuthSession),
|
||||||
|
pending: make(map[string]*PendingAuth),
|
||||||
|
}
|
||||||
|
s.startCleanup()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleanup starts a background goroutine to clean up expired sessions
|
||||||
|
func (s *SessionStore) startCleanup() {
|
||||||
|
s.cleanupOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
s.cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup removes expired sessions and pending auths
|
||||||
|
func (s *SessionStore) cleanup() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Clean up expired sessions
|
||||||
|
for id, sess := range s.sessions {
|
||||||
|
if now.After(sess.ExpiresAt) {
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old pending auths (10 minute timeout)
|
||||||
|
for state, pending := range s.pending {
|
||||||
|
if now.Sub(pending.CreatedAt) > 10*time.Minute {
|
||||||
|
delete(s.pending, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new session and returns it
|
||||||
|
func (s *SessionStore) CreateSession(did, handle string) (*OAuthSession, error) {
|
||||||
|
id, err := generateRandomID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
session := &OAuthSession{
|
||||||
|
ID: id,
|
||||||
|
DID: did,
|
||||||
|
Handle: handle,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(sessionTTL),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[id] = session
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves a session by ID
|
||||||
|
func (s *SessionStore) GetSession(id string) *OAuthSession {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
session, ok := s.sessions[id]
|
||||||
|
if !ok || time.Now().After(session.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSession updates a session
|
||||||
|
func (s *SessionStore) UpdateSession(session *OAuthSession) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession removes a session
|
||||||
|
func (s *SessionStore) DeleteSession(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePending saves pending OAuth state
|
||||||
|
func (s *SessionStore) SavePending(state string, pending *PendingAuth) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
pending.CreatedAt = time.Now()
|
||||||
|
s.pending[state] = pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPending retrieves and removes pending OAuth state
|
||||||
|
func (s *SessionStore) GetPending(state string) *PendingAuth {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
pending, ok := s.pending[state]
|
||||||
|
if ok {
|
||||||
|
delete(s.pending, state)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomID generates a random session ID
|
||||||
|
func generateRandomID() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptSessionID encrypts a session ID using AES-256-GCM
|
||||||
|
func encryptSessionID(sessionID string, key []byte) (string, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(sessionID), nil)
|
||||||
|
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptSessionID decrypts a session ID using AES-256-GCM
|
||||||
|
func decryptSessionID(encrypted string, key []byte) (string, error) {
|
||||||
|
ciphertext, err := base64.URLEncoding.DecodeString(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ciphertext) < gcm.NonceSize() {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionCookie sets an encrypted session cookie
|
||||||
|
func (m *OAuthManager) SetSessionCookie(w http.ResponseWriter, sessionID string) error {
|
||||||
|
encrypted, err := encryptSessionID(sessionID, m.cookieSecret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: encrypted,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionFromCookie retrieves the session from the request cookie
|
||||||
|
func (m *OAuthManager) GetSessionFromCookie(r *http.Request) *OAuthSession {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := decryptSessionID(cookie.Value, m.cookieSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.sessions.GetSession(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie removes the session cookie
|
||||||
|
func (m *OAuthManager) ClearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionInfo is the public session info returned to the client
|
||||||
|
type SessionInfo struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON converts SessionInfo to JSON
|
||||||
|
func (s *SessionInfo) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias SessionInfo
|
||||||
|
return json.Marshal((*Alias)(s))
|
||||||
|
}
|
||||||
@@ -3,13 +3,58 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Crawler) StartDashboard(addr string) error {
|
func (c *Crawler) StartDashboard(addr string) error {
|
||||||
http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
// Determine base URL for OAuth
|
||||||
|
baseURL := os.Getenv("OAUTH_BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
// Default based on whether we're in production
|
||||||
|
if strings.Contains(addr, "0.0.0.0") {
|
||||||
|
baseURL = "https://app.1440.news"
|
||||||
|
} else {
|
||||||
|
baseURL = "http://" + addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize OAuth manager
|
||||||
|
oauthCfg, err := LoadOAuthConfig(baseURL)
|
||||||
|
var oauth *OAuthManager
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("OAuth not configured: %v (dashboard will be unprotected)\n", err)
|
||||||
|
} else {
|
||||||
|
oauth, err = NewOAuthManager(*oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to initialize OAuth: %v (dashboard will be unprotected)\n", err)
|
||||||
|
oauth = nil
|
||||||
|
} else {
|
||||||
|
fmt.Println("OAuth authentication enabled for dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth endpoints (always public)
|
||||||
|
if oauth != nil {
|
||||||
|
http.HandleFunc("/.well-known/oauth-client-metadata", oauth.HandleClientMetadata)
|
||||||
|
http.HandleFunc("/.well-known/jwks.json", oauth.HandleJWKS)
|
||||||
|
http.HandleFunc("/auth/login", oauth.HandleLogin)
|
||||||
|
http.HandleFunc("/auth/callback", oauth.HandleCallback)
|
||||||
|
http.HandleFunc("/auth/logout", oauth.HandleLogout)
|
||||||
|
http.HandleFunc("/auth/session", oauth.HandleSessionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to wrap handlers with auth if OAuth is enabled
|
||||||
|
withAuth := func(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
if oauth != nil {
|
||||||
|
return oauth.RequireAuth(h)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/dashboard", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleDashboard(w, r)
|
c.handleDashboard(w, r)
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Root handler for url.1440.news short URLs and 1440.news accounts directory
|
// Root handler for url.1440.news short URLs and 1440.news accounts directory
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -43,117 +88,117 @@ func (c *Crawler) StartDashboard(addr string) error {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/stats", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIStats(w, r)
|
c.handleAPIStats(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/allDomains", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/allDomains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIAllDomains(w, r)
|
c.handleAPIAllDomains(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/domainFeeds", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/domainFeeds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDomainFeeds(w, r)
|
c.handleAPIDomainFeeds(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/feedInfo", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/feedInfo", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIFeedInfo(w, r)
|
c.handleAPIFeedInfo(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/feedItems", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/feedItems", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIFeedItems(w, r)
|
c.handleAPIFeedItems(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/search", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPISearch(w, r)
|
c.handleAPISearch(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/tlds", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/tlds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPITLDs(w, r)
|
c.handleAPITLDs(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/tldDomains", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/tldDomains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPITLDDomains(w, r)
|
c.handleAPITLDDomains(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/revisitDomain", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/revisitDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIRevisitDomain(w, r)
|
c.handleAPIRevisitDomain(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/priorityCrawl", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/priorityCrawl", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPriorityCrawl(w, r)
|
c.handleAPIPriorityCrawl(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/checkFeed", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/checkFeed", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPICheckFeed(w, r)
|
c.handleAPICheckFeed(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/domainsByStatus", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/domainsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDomainsByStatus(w, r)
|
c.handleAPIDomainsByStatus(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/feedsByStatus", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/feedsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIFeedsByStatus(w, r)
|
c.handleAPIFeedsByStatus(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/domains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDomains(w, r)
|
c.handleAPIDomains(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/feeds", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/feeds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIFeeds(w, r)
|
c.handleAPIFeeds(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/setDomainStatus", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/setDomainStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPISetDomainStatus(w, r)
|
c.handleAPISetDomainStatus(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/filter", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/filter", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIFilter(w, r)
|
c.handleAPIFilter(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/enablePublish", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/enablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIEnablePublish(w, r)
|
c.handleAPIEnablePublish(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/disablePublish", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/disablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDisablePublish(w, r)
|
c.handleAPIDisablePublish(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/publishEnabled", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/publishEnabled", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPublishEnabled(w, r)
|
c.handleAPIPublishEnabled(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/publishDenied", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/publishDenied", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPublishDenied(w, r)
|
c.handleAPIPublishDenied(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/publishCandidates", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/publishCandidates", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPublishCandidates(w, r)
|
c.handleAPIPublishCandidates(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/setPublishStatus", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/setPublishStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPISetPublishStatus(w, r)
|
c.handleAPISetPublishStatus(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/unpublishedItems", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/unpublishedItems", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIUnpublishedItems(w, r)
|
c.handleAPIUnpublishedItems(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/testPublish", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/testPublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPITestPublish(w, r)
|
c.handleAPITestPublish(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/deriveHandle", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/deriveHandle", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDeriveHandle(w, r)
|
c.handleAPIDeriveHandle(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/publishFeed", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/publishFeed", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPublishFeed(w, r)
|
c.handleAPIPublishFeed(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/createAccount", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/createAccount", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPICreateAccount(w, r)
|
c.handleAPICreateAccount(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/publishFeedFull", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/publishFeedFull", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIPublishFeedFull(w, r)
|
c.handleAPIPublishFeedFull(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/updateProfile", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIUpdateProfile(w, r)
|
c.handleAPIUpdateProfile(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/languages", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPILanguages(w, r)
|
c.handleAPILanguages(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/denyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDenyDomain(w, r)
|
c.handleAPIDenyDomain(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/undenyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIUndenyDomain(w, r)
|
c.handleAPIUndenyDomain(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/dropDomain", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/dropDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIDropDomain(w, r)
|
c.handleAPIDropDomain(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/tldStats", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/tldStats", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPITLDStats(w, r)
|
c.handleAPITLDStats(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/resetAllPublishing", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/resetAllPublishing", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIResetAllPublishing(w, r)
|
c.handleAPIResetAllPublishing(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/api/refreshProfiles", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/refreshProfiles", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIRefreshProfiles(w, r)
|
c.handleAPIRefreshProfiles(w, r)
|
||||||
})
|
}))
|
||||||
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
|
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user