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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
/server/certs/*.key
/server/.env
/server/**/*.sql
# ...except the canonical table definitions, which are source, not generated
# output: the CMS go:embeds them, so a checkout without them does not compile.
!/server/internal/database/schema/*.sql
/report.json
.vscode/settings.json
.codex
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ Generated files:
- `04-seo.sql`
- `05-article-embeddings.sql` (real file when available, placeholder otherwise)
- `06-taxonomy.sql`
- `07-poll-counts.sql`
- `08-comments.sql` (real file when available, empty table placeholder otherwise)

## API Docs (Swagger)

Expand Down
3 changes: 3 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ The file must contain the exact immutable image tag for the active deployment:
- `CMS_SESSION_TTL_SECONDS`
- `CMS_AUTO_PROMOTE_ALL_ADMINS`
- `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP`
- `AKISMET_API_KEY` - optional; leave empty to disable comment spam filtering.
- `AKISMET_BLOG_URL` - full public site URL Akismet should associate with
comment checks. Required when `AKISMET_API_KEY` is set.
- `MEDIA_HOST_PATH` - host path to the CephFS media tree, bind-mounted into the
backend. Defaults to `/mnt/cephfs/media`.
- `MEDIA_ROOT` - the same tree as seen *inside* the container. Leave at
Expand Down
2 changes: 2 additions & 0 deletions deploy/cms.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ OIDC_REDIRECT_URI=
CMS_SESSION_TTL_SECONDS=
CMS_AUTO_PROMOTE_ALL_ADMINS=
CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=
AKISMET_API_KEY=
AKISMET_BLOG_URL=
MEDIA_HOST_PATH=
MEDIA_ROOT=
MEDIA_BASE_URL=
2 changes: 2 additions & 0 deletions deploy/compose.cms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ x-backend-base: &backend-base
CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800}
CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false}
CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false}
AKISMET_API_KEY: ${AKISMET_API_KEY:-}
AKISMET_BLOG_URL: ${AKISMET_BLOG_URL:-}
# Media: legacy WP uploads migrated to CephFS. The upload endpoint writes new
# assets under MEDIA_ROOT; MEDIA_BASE_URL is the public host that serves them.
MEDIA_ROOT: ${MEDIA_ROOT:-/mnt/cephfs/media}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- ./server/internal/database/wordpress_etl/05-article-embeddings.sql:/docker-entrypoint-initdb.d/05-article-embeddings.sql:ro,z
- ./server/internal/database/wordpress_etl/06-taxonomy.sql:/docker-entrypoint-initdb.d/06-taxonomy.sql:ro,z
- ./server/internal/database/wordpress_etl/07-poll-counts.sql:/docker-entrypoint-initdb.d/07-poll-counts.sql:ro,z
- ./server/internal/database/wordpress_etl/08-comments.sql:/docker-entrypoint-initdb.d/08-comments.sql:ro,z
ports:
- "127.0.0.1:${MARIADB_PORT_FORWARD:-3306}:3306"
healthcheck:
Expand Down
117 changes: 93 additions & 24 deletions scripts/generate_wordpress_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,9 @@
from pathlib import Path


TAXONOMY_SQL = """DROP TABLE IF EXISTS site_taxonomy;
CREATE TABLE site_taxonomy (
id BIGINT PRIMARY KEY,
kind VARCHAR(32) NOT NULL,
slug VARCHAR(255) NOT NULL,
canonical_title VARCHAR(255) NOT NULL,
parent_slug VARCHAR(255) NULL,
article_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY uq_site_taxonomy_kind_slug (kind, slug)
);

INSERT INTO site_taxonomy (id, kind, slug, canonical_title, parent_slug, article_count) VALUES
# Seed ROWS only. The table definition comes from the canonical schema file --
# see canonical_schema() and server/internal/database/schema/README.md.
TAXONOMY_ROWS_SQL = """INSERT INTO site_taxonomy (id, kind, slug, canonical_title, parent_slug, article_count) VALUES
(1, 'section', 'news', 'News', NULL, 0),
(2, 'section', 'sports', 'Sports', NULL, 0),
(3, 'section', 'opinion', 'Opinion', NULL, 0),
Expand Down Expand Up @@ -64,17 +55,33 @@
-- No article_embeddings.sql found in ETL output.
"""

POLL_COUNTS_SQL = """DROP TABLE IF EXISTS cms_poll_counts;
CREATE TABLE cms_poll_counts (
option_name VARCHAR(128) NOT NULL,
vote_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (option_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""

NO_AUTO_VALUE_ON_ZERO_PREAMBLE = "SET sql_mode = CONCAT(@@sql_mode, ',NO_AUTO_VALUE_ON_ZERO');\n"

# Canonical table definitions, shared with the CMS. The same files are embedded
# into the binary and executed at startup (server/internal/database/schema.go),
# so a seeded dev database and production cannot drift apart. Never inline a
# CREATE TABLE for a CMS-owned table here -- that is the bug this replaced.
SCHEMA_DIR_PARTS = ("server", "internal", "database", "schema")


def canonical_schema(root_dir: Path, table: str, *, drop_first: bool = False) -> str:
"""Return the canonical CREATE TABLE for `table`, as a runnable statement."""
path = root_dir.joinpath(*SCHEMA_DIR_PARTS) / f"{table}.sql"
try:
body = path.read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"Missing canonical schema for {table}: {path} ({exc})", file=sys.stderr)
print("The CMS embeds these files; the checkout is incomplete.", file=sys.stderr)
raise SystemExit(1)

statement = f"{body};\n"
if drop_first:
# Seed files are re-runnable and own their table outright, so they start
# from a clean one rather than layering onto whatever is there.
statement = f"DROP TABLE IF EXISTS {table};\n{statement}"
return statement


USAGE_ERROR = """Could not determine WordPress ETL SQL source directory.

Expected one of:
Expand All @@ -91,6 +98,7 @@

Optional ETL SQL file:
- article_embeddings.sql
- comments.sql

Examples:
python ./scripts/generate_wordpress_sql.py ../wordpress-etl
Expand Down Expand Up @@ -154,12 +162,48 @@ def ensure_pattern(path: Path, pattern: str, label: str) -> None:
raise SystemExit(1)


# Matches an actual statement that turns the mode on, not a mere mention of the
# name. A bare substring test also matches a comment, which would silently skip
# the preamble and let the id=0 row be renumbered on load.
SQL_MODE_SET_PATTERN = re.compile(
r"^\s*SET\b[^;]*\bsql_mode\b[^;]*\bNO_AUTO_VALUE_ON_ZERO\b",
re.IGNORECASE | re.MULTILINE,
)

# Any statement that writes rows; such a file must carry the sql_mode preamble.
INSERT_PATTERN = re.compile(r"^\s*(INSERT|REPLACE|LOAD\s+DATA)\b", re.IGNORECASE | re.MULTILINE)


def has_no_auto_value_on_zero(text: str) -> bool:
return SQL_MODE_SET_PATTERN.search(text) is not None


def copy_sql_with_mariadb_mode(src: Path, dest: Path) -> None:
"""Copy an ETL artifact, guaranteeing NO_AUTO_VALUE_ON_ZERO is in effect.

`articles` has a legitimate row with id = 0. Without this mode MariaDB
treats 0 in an AUTO_INCREMENT column as "assign the next value", so that row
silently becomes id = 1 (or whatever is next) and every articles_authors /
seo row pointing at 0 is orphaned. sql_mode is session-scoped and each seed
file is loaded in its own session, so the statement has to lead every file
that inserts rows -- it cannot be set once globally for the batch.
"""
text = src.read_text(encoding="utf-8", errors="replace")
if "NO_AUTO_VALUE_ON_ZERO" not in text:
if not has_no_auto_value_on_zero(text):
text = NO_AUTO_VALUE_ON_ZERO_PREAMBLE + text
dest.write_text(text, encoding="utf-8")

# Verify what actually landed on disk rather than trusting the branch above:
# this is the last point at which the mistake is cheap to catch.
written = dest.read_text(encoding="utf-8")
if INSERT_PATTERN.search(written) and not has_no_auto_value_on_zero(written):
print(
f"refusing to emit {dest.name}: it inserts rows without "
"NO_AUTO_VALUE_ON_ZERO, which would renumber the articles id=0 row",
file=sys.stderr,
)
raise SystemExit(1)


def main() -> int:
args = parse_args()
Expand Down Expand Up @@ -260,6 +304,17 @@ def main() -> int:
src_grandparent / "sql" / "article_embeddings.sql",
]
)
comments_sql = first_existing_file(
[
src_dir / "comments.sql",
src_parent / "comments.sql",
src_parent / "logs" / "sql" / "comments.sql",
src_parent / "sql" / "comments.sql",
src_grandparent / "comments.sql",
src_grandparent / "logs" / "sql" / "comments.sql",
src_grandparent / "sql" / "comments.sql",
]
)

authors_sql = require_file(authors_sql, "authors.sql")
articles_sql = require_file(articles_sql, "articles.sql")
Expand All @@ -279,6 +334,7 @@ def main() -> int:
out_embeddings = out_dir / "05-article-embeddings.sql"
out_taxonomy = out_dir / "06-taxonomy.sql"
out_poll_counts = out_dir / "07-poll-counts.sql"
out_comments = out_dir / "08-comments.sql"

copy_sql_with_mariadb_mode(authors_sql, out_authors)
copy_sql_with_mariadb_mode(articles_sql, out_articles)
Expand All @@ -290,8 +346,17 @@ def main() -> int:
else:
out_embeddings.write_text(PLACEHOLDER_EMBEDDINGS_SQL, encoding="utf-8")

out_taxonomy.write_text(TAXONOMY_SQL, encoding="utf-8")
out_poll_counts.write_text(POLL_COUNTS_SQL, encoding="utf-8")
out_taxonomy.write_text(
canonical_schema(root_dir, "site_taxonomy", drop_first=True) + "\n" + TAXONOMY_ROWS_SQL,
encoding="utf-8",
)
out_poll_counts.write_text(
canonical_schema(root_dir, "cms_poll_counts", drop_first=True), encoding="utf-8"
)
if comments_sql is not None:
copy_sql_with_mariadb_mode(comments_sql, out_comments)
else:
out_comments.write_text(canonical_schema(root_dir, "comments"), encoding="utf-8")

print(f"Imported ETL SQL into: {out_dir}")
print(f" 01-authors.sql <- {authors_sql}")
Expand All @@ -304,6 +369,10 @@ def main() -> int:
print(" 05-article-embeddings.sql <- placeholder (no ETL embeddings artifact found)")
print(" 06-taxonomy.sql <- cms static taxonomy seed")
print(" 07-poll-counts.sql <- cms poll counts schema seed")
if comments_sql is not None:
print(f" 08-comments.sql <- {comments_sql}")
else:
print(" 08-comments.sql <- placeholder (no ETL comments artifact found)")
return 0


Expand Down
156 changes: 156 additions & 0 deletions server/internal/akismet/akismet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package akismet

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

const (
defaultEndpoint = "https://rest.akismet.com/1.1/comment-check"
defaultUserAgent = "TriangleCMS/1.0 | Akismet/1.1"
defaultTimeout = 3 * time.Second
)

type Checker interface {
CheckComment(ctx context.Context, comment Comment) (bool, error)
}

type Config struct {
APIKey string
BlogURL string
Endpoint string
UserAgent string
HTTPClient *http.Client
}

type Comment struct {
UserIP string
UserAgent string
Referrer string
Permalink string
Type string
Author string
AuthorEmail string
AuthorURL string
Content string
CreatedAt time.Time
IsTest bool
}

type Client struct {
apiKey string
blogURL string
endpoint string
userAgent string
httpClient *http.Client
}

func NewClient(cfg Config) (*Client, error) {
apiKey := strings.TrimSpace(cfg.APIKey)
if apiKey == "" {
return nil, fmt.Errorf("akismet api key is required")
}

blogURL := strings.TrimSpace(cfg.BlogURL)
if blogURL == "" {
return nil, fmt.Errorf("akismet blog url is required")
}
parsedBlogURL, err := url.ParseRequestURI(blogURL)
if err != nil || parsedBlogURL == nil || parsedBlogURL.Scheme == "" || parsedBlogURL.Host == "" {
return nil, fmt.Errorf("akismet blog url must be a full URL")
}

endpoint := strings.TrimSpace(cfg.Endpoint)
if endpoint == "" {
endpoint = defaultEndpoint
}
parsedEndpoint, err := url.ParseRequestURI(endpoint)
if err != nil || parsedEndpoint == nil || parsedEndpoint.Scheme == "" || parsedEndpoint.Host == "" {
return nil, fmt.Errorf("akismet endpoint must be a full URL")
}

userAgent := strings.TrimSpace(cfg.UserAgent)
if userAgent == "" {
userAgent = defaultUserAgent
}

httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: defaultTimeout}
}

return &Client{
apiKey: apiKey,
blogURL: blogURL,
endpoint: endpoint,
userAgent: userAgent,
httpClient: httpClient,
}, nil
}

func (c *Client) CheckComment(ctx context.Context, comment Comment) (bool, error) {
values := url.Values{}
values.Set("api_key", c.apiKey)
values.Set("blog", c.blogURL)
values.Set("user_ip", strings.TrimSpace(comment.UserIP))
values.Set("user_agent", strings.TrimSpace(comment.UserAgent))
values.Set("referrer", strings.TrimSpace(comment.Referrer))
values.Set("permalink", strings.TrimSpace(comment.Permalink))
values.Set("comment_type", valueOrDefault(comment.Type, "comment"))
values.Set("comment_author", strings.TrimSpace(comment.Author))
values.Set("comment_author_email", strings.TrimSpace(comment.AuthorEmail))
values.Set("comment_author_url", strings.TrimSpace(comment.AuthorURL))
values.Set("comment_content", strings.TrimSpace(comment.Content))
values.Set("blog_charset", "UTF-8")
if !comment.CreatedAt.IsZero() {
values.Set("comment_date_gmt", comment.CreatedAt.UTC().Format(time.RFC3339))
}
if comment.IsTest {
values.Set("is_test", "1")
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(values.Encode()))
if err != nil {
return false, fmt.Errorf("create akismet request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", c.userAgent)

resp, err := c.httpClient.Do(req)
if err != nil {
return false, fmt.Errorf("akismet comment check failed: %w", err)
}
defer resp.Body.Close()

bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
if err != nil {
return false, fmt.Errorf("read akismet response: %w", err)
}
body := strings.TrimSpace(string(bodyBytes))

if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("akismet returned %s: %s", resp.Status, body)
}

switch body {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("akismet returned unexpected response %q", body)
}
}

func valueOrDefault(value, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
Loading
Loading