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:
primal
2026-01-30 15:16:51 -05:00
parent 1a2f6c15a9
commit 8192bce301
11 changed files with 1446 additions and 84 deletions
+1
View File
@@ -5,3 +5,4 @@ feeds/
feeds.db/
1440.db
pds.env
oauth.env
+54
View File
@@ -2,6 +2,8 @@
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
```bash
@@ -67,6 +69,11 @@ The application runs seven independent goroutine loops:
| `db.go` | PostgreSQL schema (domains, feeds, items tables with tsvector FTS) |
| `dashboard.go` | HTTP server, JSON APIs, HTML template |
| `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
@@ -128,3 +135,50 @@ PDS configuration in `pds.env`:
PDS_HOST=https://pds.1440.news
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
+45
View File
@@ -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!")
}
+1
View File
@@ -6,6 +6,7 @@ services:
stop_grace_period: 30s
env_file:
- pds.env
- oauth.env
environment:
DB_HOST: atproto-postgres
DB_PORT: 5432
+24
View File
@@ -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
+287
View File
@@ -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)
}
+484
View File
@@ -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>
`
+124
View File
@@ -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
}
+297
View File
@@ -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))
}
+121 -76
View File
@@ -3,13 +3,58 @@ package main
import (
"fmt"
"net/http"
"os"
"strings"
)
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)
})
}))
// Root handler for url.1440.news short URLs and 1440.news accounts directory
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.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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
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)
})
}))
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
})