webring/internal/api/handlers.go
Alexander Karpov bf62b8f427
added telegram oauth, submittions, dispaly order, admin
* major changes

Introduce full user system and approval workflow
——————————————————————————————————————————

Login & sessions

    Telegram one‑click login (/login → /auth/telegram) with HMAC verification

    New users and sessions tables; telegram_id now optional, TTL‑based cleanup job

    Secure session_id cookie (configurable TTL and Secure/SameSite flags)

User dashboard (/user)

    Lists the member’s sites and their uptime status

    Forms to submit new site or update requests; validation and slug/url sanitisation

    View pending requests with change diff

Request storage

    update_requests table captures create/update ops as JSONB “changed_fields”

Admin review

    /admin/requests interface to approve / reject queued requests

    Approval auto‑creates sites (with ordered display_order) or patches existing ones, then refreshes favicon

Super‑admin panel

    /admin/setup lists all users, toggle is_admin and forcibly logs them out

Notifications

    On every new request, all admins with a Telegram ID receive a Markdown summary via bot API

Public UI tweaks

    Header shows login/logout, role‑aware links and call‑to‑action cards

    /submit page creates a queued request

Config & env

    Added TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, SESSION_TTL_HOURS, SESSION_SECURE_COOKIE

    .env.template updated accordingly

Migrations 004–010

    Users, sessions, foreign key on sites, display_order, update_requests, telegram_id nullability

BREAKING CHANGE

    Environment must supply Telegram bot credentials

    Database must be migrated; existing “dashboard” auth remains but admin routes are now session‑protected where applicable
2025-08-08 16:36:09 +03:00

419 lines
13 KiB
Go

package api
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"webring/internal/api/middleware"
"webring/internal/models"
"github.com/gorilla/mux"
)
func RegisterHandlers(r *mux.Router, db *sql.DB) {
apiRouter := r.PathPrefix("").Subrouter()
apiRouter.Use(middleware.CORSMiddleware)
apiRouter.HandleFunc("/{slug}/prev/data", previousSiteHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/next/data", nextSiteHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/prev", previousSiteRedirectHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/next", nextSiteRedirectHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/data", siteDataHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/random/data", randomSiteHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}/random", randomSiteRedirectHandler(db)).Methods("GET")
apiRouter.HandleFunc("/sites", listPublicSitesHandler(db)).Methods("GET")
apiRouter.HandleFunc("/{slug}", currentSiteRedirectHandler(db)).Methods("GET")
}
func nextSiteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
site, err := getNextSite(db, slug)
if err != nil {
log.Printf("Error getting next site for %s: %v", slug, err)
http.Error(w, "Site not found or no next site available", http.StatusNotFound)
return
}
response := struct {
Next *models.PublicSite `json:"next"`
}{
Next: site,
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
http.Error(w, "Error encoding response", http.StatusInternalServerError)
return
}
}
}
func previousSiteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
site, err := getPreviousSite(db, slug)
if err != nil {
log.Printf("Error getting previous site for %s: %v", slug, err)
http.Error(w, "Site not found or no previous site available", http.StatusNotFound)
return
}
response := struct {
Previous *models.PublicSite `json:"previous"`
}{
Previous: site,
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
http.Error(w, "Error encoding response", http.StatusInternalServerError)
return
}
}
}
func randomSiteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSlug := mux.Vars(r)["slug"]
site, err := getRandomSite(db, currentSlug)
if err != nil {
if err.Error() == "no available sites found" {
http.Error(w, "No available sites found", http.StatusNotFound)
} else {
log.Printf("Error fetching random site: %v", err)
http.Error(w, "Error fetching random site", http.StatusInternalServerError)
}
return
}
response := struct {
Random *models.PublicSite `json:"random"`
}{
Random: site,
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
}
func siteDataHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
data, err := getSiteData(db, slug)
if err != nil {
http.Error(w, "Site not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Error encoding response", http.StatusInternalServerError)
return
}
}
}
func currentSiteRedirectHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
var url string
var isUp bool
err := db.QueryRow("SELECT url, is_up FROM sites WHERE slug = $1", slug).Scan(&url, &isUp)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Site not found", http.StatusNotFound)
} else {
log.Printf("Error fetching site %s: %v", slug, err)
http.Error(w, "Error fetching site", http.StatusInternalServerError)
}
return
}
if !isUp {
http.Error(w, "Site is currently down", http.StatusServiceUnavailable)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
}
func previousSiteRedirectHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
site, err := getPreviousSite(db, slug)
if err != nil {
http.Error(w, "Site not found", http.StatusNotFound)
return
}
http.Redirect(w, r, site.URL, http.StatusFound)
}
}
func nextSiteRedirectHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := mux.Vars(r)["slug"]
site, err := getNextSite(db, slug)
if err != nil {
http.Error(w, "Site not found", http.StatusNotFound)
return
}
http.Redirect(w, r, site.URL, http.StatusFound)
}
}
func randomSiteRedirectHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentSlug := mux.Vars(r)["slug"]
site, err := getRandomSite(db, currentSlug)
if err != nil {
if err.Error() == "no available sites found" {
http.Error(w, "No available sites found", http.StatusNotFound)
} else {
log.Printf("Error fetching random site: %v", err)
http.Error(w, "Error fetching random site", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, site.URL, http.StatusFound)
}
}
func listPublicSitesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
sites, err := getRespondingSites(db)
if err != nil {
http.Error(w, "Error fetching sites", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(sites); err != nil {
http.Error(w, "Error encoding response", http.StatusInternalServerError)
return
}
}
}
func getRespondingSites(db *sql.DB) ([]models.PublicSite, error) {
rows, err := db.Query("SELECT id, slug, name, url, favicon FROM sites WHERE is_up = true ORDER BY display_order")
if err != nil {
return nil, err
}
defer func() {
if closeErr := rows.Close(); closeErr != nil {
log.Printf("Error closing rows: %v", closeErr)
}
}()
var sites []models.PublicSite
for rows.Next() {
var site models.PublicSite
if scanErr := rows.Scan(&site.ID, &site.Slug, &site.Name, &site.URL, &site.Favicon); scanErr != nil {
return nil, scanErr
}
sites = append(sites, site)
}
if rowsErr := rows.Err(); rowsErr != nil {
return nil, rowsErr
}
return sites, nil
}
func getNextSite(db *sql.DB, currentSlug string) (*models.PublicSite, error) {
query := `
WITH c AS (
SELECT display_order as corder
FROM sites
WHERE slug = $1
),
pick AS (
SELECT COALESCE(
(SELECT MIN(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE
AND s2.display_order > c.corder),
(SELECT MIN(s3.display_order)
FROM sites s3
WHERE s3.is_up = TRUE)
) AS next_order
FROM c
)
SELECT s.id, s.slug, s.name, s.url, s.favicon
FROM pick
LEFT JOIN sites s ON s.display_order = pick.next_order
WHERE s.is_up = TRUE
`
var site models.PublicSite
err := db.QueryRow(query, currentSlug).Scan(&site.ID, &site.Slug, &site.Name, &site.URL, &site.Favicon)
if err != nil {
return nil, fmt.Errorf("no next site found: %w", err)
}
// If we get 0, it means no up sites at all
if site.ID == 0 {
return nil, fmt.Errorf("no available sites found (zero up sites)")
}
return &site, nil
}
func getPreviousSite(db *sql.DB, currentSlug string) (*models.PublicSite, error) {
query := `
WITH c AS (
SELECT display_order as corder
FROM sites
WHERE slug = $1
),
pick AS (
SELECT COALESCE(
(SELECT MAX(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE
AND s2.display_order < c.corder),
(SELECT MAX(s3.display_order)
FROM sites s3
WHERE s3.is_up = TRUE)
) AS prev_order
FROM c
)
SELECT s.id, s.slug, s.name, s.url, s.favicon
FROM pick
LEFT JOIN sites s ON s.display_order = pick.prev_order
WHERE s.is_up = TRUE
`
var site models.PublicSite
err := db.QueryRow(query, currentSlug).Scan(&site.ID, &site.Slug, &site.Name, &site.URL, &site.Favicon)
if err != nil {
return nil, fmt.Errorf("no previous site found: %w", err)
}
if site.ID == 0 {
return nil, fmt.Errorf("no available sites found (zero up sites)")
}
return &site, nil
}
func getSiteData(db *sql.DB, slug string) (*models.SiteData, error) {
query := `
WITH current_site AS (
SELECT id, slug, name, url, favicon, is_up, display_order
FROM sites
WHERE slug = $1
),
ring AS (
SELECT
c.id AS curr_id,
c.slug AS curr_slug,
c.name AS curr_name,
c.url AS curr_url,
c.favicon AS curr_favicon,
c.is_up AS curr_is_up,
c.display_order AS curr_order,
COALESCE(
(SELECT MAX(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE AND s2.display_order < c.display_order),
(SELECT MAX(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE)
) AS final_prev_order,
COALESCE(
(SELECT MIN(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE AND s2.display_order > c.display_order),
(SELECT MIN(s2.display_order)
FROM sites s2
WHERE s2.is_up = TRUE)
) AS final_next_order
FROM current_site c
)
SELECT
/* Prev site */
COALESCE(prevs.id, 0) AS prev_id,
COALESCE(prevs.slug, '') AS prev_slug,
COALESCE(prevs.name, '') AS prev_name,
COALESCE(prevs.url, '') AS prev_url,
COALESCE(prevs.favicon, '') AS prev_favicon,
/* Current site (could be up or down) */
ring.curr_id AS curr_id,
ring.curr_slug AS curr_slug,
ring.curr_name AS curr_name,
ring.curr_url AS curr_url,
COALESCE(ring.curr_favicon, '') AS curr_favicon,
/* Next site */
COALESCE(nexts.id, 0) AS next_id,
COALESCE(nexts.slug, '') AS next_slug,
COALESCE(nexts.name, '') AS next_name,
COALESCE(nexts.url, '') AS next_url,
COALESCE(nexts.favicon, '') AS next_favicon
FROM ring
LEFT JOIN sites prevs ON prevs.display_order = ring.final_prev_order AND prevs.is_up = TRUE
LEFT JOIN sites nexts ON nexts.display_order = ring.final_next_order AND nexts.is_up = TRUE
`
var data models.SiteData
err := db.QueryRow(query, slug).Scan(
&data.Prev.ID, &data.Prev.Slug, &data.Prev.Name, &data.Prev.URL, &data.Prev.Favicon,
&data.Curr.ID, &data.Curr.Slug, &data.Curr.Name, &data.Curr.URL, &data.Curr.Favicon,
&data.Next.ID, &data.Next.Slug, &data.Next.Name, &data.Next.URL, &data.Next.Favicon,
)
if err != nil {
// If we got sql.ErrNoRows, it means there's no site with this ID
return nil, err
}
return &data, nil
}
func getRandomSite(db *sql.DB, currentSlug string) (*models.PublicSite, error) {
var site models.PublicSite
err := db.QueryRow(`
SELECT id, slug, name, url, favicon
FROM sites
WHERE is_up = true AND slug != $1
ORDER BY RANDOM()
LIMIT 1
`, currentSlug).Scan(&site.ID, &site.Slug, &site.Name, &site.URL, &site.Favicon)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// No other sites available, try to get any site (including current)
err = db.QueryRow(`
SELECT id, slug, name, url, favicon
FROM sites
WHERE is_up = true
ORDER BY RANDOM()
LIMIT 1
`).Scan(&site.ID, &site.Slug, &site.Name, &site.URL, &site.Favicon)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("no available sites found")
}
return nil, fmt.Errorf("database error: %v", err)
}
} else {
return nil, fmt.Errorf("database error: %v", err)
}
}
return &site, nil
}