diff --git a/.github/workflows/linkedin.yml b/.github/workflows/linkedin.yml new file mode 100644 index 00000000..b6b77bf2 --- /dev/null +++ b/.github/workflows/linkedin.yml @@ -0,0 +1,131 @@ +name: Publish to LinkedIn + +# Required repo secrets (set BEFORE this workflow first runs): +# - LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET (LinkedIn Developer Portal) +# - DATABASE_URL (Neon connection string — same value Fly uses) +# - ENCRYPTION_SECRET_KEY (same value Fly uses; required to decrypt tokens) +# Also requires: APP_ID, APP_PRIVATE_KEY (already used by bluesky.yml). + +on: + # Chain off a successful Fly Deploy so the blog/podcast page for new content + # is live BEFORE the LinkedIn post (which links to that page) gets created. + workflow_run: + workflows: ["Fly Deploy"] + branches: [main] + types: [completed] + workflow_dispatch: + +concurrency: + group: linkedin-publish + cancel-in-progress: false + +env: + SQLX_OFFLINE: true + APP_BASE_URL: https://coreyja.com + +jobs: + publish: + name: Publish to LinkedIn + runs-on: ubuntu-latest + # workflow_dispatch always runs. workflow_run only runs when the + # upstream Fly Deploy succeeded; a failed deploy means the page + # isn't live, so we shouldn't publish a link to it. + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + env: + CARGO_INCREMENTAL: 0 + steps: + - name: Generate App token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Setup | Checkout + uses: actions/checkout@v4 + with: + # Always check out the tip of main, not the SHA that triggered the + # workflow. Without this, a queued run for an older commit could see + # stale posts (missing linkedin_url updates pushed by an earlier run) + # and re-publish them. + ref: main + # Authenticate git operations as the App so the push bypasses the + # main-branch ruleset (App is a bypass actor) and so the push + # triggers downstream workflows (App tokens trigger runs, unlike + # the default GITHUB_TOKEN). + token: ${{ steps.app-token.outputs.token }} + + - uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: protobuf-compiler libasound2-dev + version: v0 + + - name: Install rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - uses: Swatinem/rust-cache@v2 + + - name: Build Tailwind CSS + run: | + curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 && \ + chmod +x tailwindcss-linux-x64 && \ + mv tailwindcss-linux-x64 tailwindcss && \ + ./tailwindcss -i server/src/styles/tailwind.css -o target/tailwind.css + + - name: Build release binary + run: cargo build --release + + - name: Publish blog posts + id: publish_blog + continue-on-error: true + env: + LINKEDIN_CLIENT_ID: ${{ secrets.LINKEDIN_CLIENT_ID }} + LINKEDIN_CLIENT_SECRET: ${{ secrets.LINKEDIN_CLIENT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ENCRYPTION_SECRET_KEY: ${{ secrets.ENCRYPTION_SECRET_KEY }} + run: ./target/release/server publish-linkedin --kind blog --dir blog + + - name: Publish newsletters + id: publish_newsletter + continue-on-error: true + env: + LINKEDIN_CLIENT_ID: ${{ secrets.LINKEDIN_CLIENT_ID }} + LINKEDIN_CLIENT_SECRET: ${{ secrets.LINKEDIN_CLIENT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ENCRYPTION_SECRET_KEY: ${{ secrets.ENCRYPTION_SECRET_KEY }} + run: ./target/release/server publish-linkedin --kind newsletter --dir blog/weekly + + - name: Publish podcast episodes + id: publish_podcast + continue-on-error: true + env: + LINKEDIN_CLIENT_ID: ${{ secrets.LINKEDIN_CLIENT_ID }} + LINKEDIN_CLIENT_SECRET: ${{ secrets.LINKEDIN_CLIENT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ENCRYPTION_SECRET_KEY: ${{ secrets.ENCRYPTION_SECRET_KEY }} + run: ./target/release/server publish-linkedin --kind podcast --dir podcast + + - name: Commit linkedin_url updates + run: | + git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]" + git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" + # newsletters live under blog/weekly/, so `git add blog/` covers them. + git add blog/ podcast/ + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Add linkedin_url to syndicated posts" + git pull --rebase origin main + git push + fi + + - name: Surface publish failures + if: >- + steps.publish_blog.outcome == 'failure' + || steps.publish_newsletter.outcome == 'failure' + || steps.publish_podcast.outcome == 'failure' + run: | + echo "::error::One or more LinkedIn publish steps failed. See logs above. Next run will retry via the idempotency check." + exit 1 diff --git a/.sqlx/query-0d342a2215ee4b0ef4a263bf5b9dbdce1ac77feca8f3e82ad43690f9b44a2034.json b/.sqlx/query-0d342a2215ee4b0ef4a263bf5b9dbdce1ac77feca8f3e82ad43690f9b44a2034.json new file mode 100644 index 00000000..63ee5032 --- /dev/null +++ b/.sqlx/query-0d342a2215ee4b0ef4a263bf5b9dbdce1ac77feca8f3e82ad43690f9b44a2034.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT linkedin_oauth_state_id FROM LinkedInOauthStates WHERE state = $1 AND created_at >= $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "linkedin_oauth_state_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0d342a2215ee4b0ef4a263bf5b9dbdce1ac77feca8f3e82ad43690f9b44a2034" +} diff --git a/.sqlx/query-10825f4a25c0b4833ff08efbaf4bd1289867e517b6eae476d0858ac7fc715965.json b/.sqlx/query-10825f4a25c0b4833ff08efbaf4bd1289867e517b6eae476d0858ac7fc715965.json new file mode 100644 index 00000000..f30933ca --- /dev/null +++ b/.sqlx/query-10825f4a25c0b4833ff08efbaf4bd1289867e517b6eae476d0858ac7fc715965.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO LinkedInUsers\n (linkedin_user_id, user_id, external_linkedin_id,\n encrypted_access_token, access_token_expires_at,\n encrypted_refresh_token, refresh_token_expires_at, scope)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (user_id) DO UPDATE SET\n external_linkedin_id = EXCLUDED.external_linkedin_id,\n encrypted_access_token = EXCLUDED.encrypted_access_token,\n access_token_expires_at = EXCLUDED.access_token_expires_at,\n encrypted_refresh_token = EXCLUDED.encrypted_refresh_token,\n refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,\n scope = EXCLUDED.scope,\n updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Bytea", + "Timestamptz", + "Bytea", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "10825f4a25c0b4833ff08efbaf4bd1289867e517b6eae476d0858ac7fc715965" +} diff --git a/.sqlx/query-2f417d17ec63af6d7d4daeea05048257c01e3e5c47d84059fd817de682ab194a.json b/.sqlx/query-2f417d17ec63af6d7d4daeea05048257c01e3e5c47d84059fd817de682ab194a.json new file mode 100644 index 00000000..406880d5 --- /dev/null +++ b/.sqlx/query-2f417d17ec63af6d7d4daeea05048257c01e3e5c47d84059fd817de682ab194a.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n linkedin_user_id,\n encrypted_access_token,\n encrypted_refresh_token,\n access_token_expires_at,\n refresh_token_expires_at,\n external_linkedin_id\n FROM LinkedInUsers\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "linkedin_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "encrypted_access_token", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "encrypted_refresh_token", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "access_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "refresh_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "external_linkedin_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2f417d17ec63af6d7d4daeea05048257c01e3e5c47d84059fd817de682ab194a" +} diff --git a/.sqlx/query-3205a9bbcc7287c5ba3d41abed24015b62feb08b2cf7447c36af8a36410cc916.json b/.sqlx/query-3205a9bbcc7287c5ba3d41abed24015b62feb08b2cf7447c36af8a36410cc916.json new file mode 100644 index 00000000..3adbbe62 --- /dev/null +++ b/.sqlx/query-3205a9bbcc7287c5ba3d41abed24015b62feb08b2cf7447c36af8a36410cc916.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO LinkedInOauthStates (linkedin_oauth_state_id, state)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3205a9bbcc7287c5ba3d41abed24015b62feb08b2cf7447c36af8a36410cc916" +} diff --git a/.sqlx/query-48d2a9f0d06567e0a2bb54712683b4dd010c162717ac07e1c0e5713cbf68ac50.json b/.sqlx/query-48d2a9f0d06567e0a2bb54712683b4dd010c162717ac07e1c0e5713cbf68ac50.json new file mode 100644 index 00000000..c9d9ffbf --- /dev/null +++ b/.sqlx/query-48d2a9f0d06567e0a2bb54712683b4dd010c162717ac07e1c0e5713cbf68ac50.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT linkedin_user_id, external_linkedin_id, access_token_expires_at,\n refresh_token_expires_at, created_at, updated_at\n FROM LinkedInUsers\n WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "linkedin_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "external_linkedin_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "access_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "refresh_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "48d2a9f0d06567e0a2bb54712683b4dd010c162717ac07e1c0e5713cbf68ac50" +} diff --git a/.sqlx/query-6b427243b2cb8f2b3a5c88ddfdbd07e9638175e130463fdd2f4efa218c4751e8.json b/.sqlx/query-6b427243b2cb8f2b3a5c88ddfdbd07e9638175e130463fdd2f4efa218c4751e8.json new file mode 100644 index 00000000..46e63298 --- /dev/null +++ b/.sqlx/query-6b427243b2cb8f2b3a5c88ddfdbd07e9638175e130463fdd2f4efa218c4751e8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM LinkedInOauthStates WHERE linkedin_oauth_state_id = $1 OR created_at < $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "6b427243b2cb8f2b3a5c88ddfdbd07e9638175e130463fdd2f4efa218c4751e8" +} diff --git a/.sqlx/query-d02c222f01221c03ba04166e7439c7697e83e8b1fec4920ea8cbe63fabf64914.json b/.sqlx/query-d02c222f01221c03ba04166e7439c7697e83e8b1fec4920ea8cbe63fabf64914.json new file mode 100644 index 00000000..0a139510 --- /dev/null +++ b/.sqlx/query-d02c222f01221c03ba04166e7439c7697e83e8b1fec4920ea8cbe63fabf64914.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n linkedin_user_id,\n encrypted_access_token,\n encrypted_refresh_token,\n access_token_expires_at,\n refresh_token_expires_at,\n external_linkedin_id\n FROM LinkedInUsers\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "linkedin_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "encrypted_access_token", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "encrypted_refresh_token", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "access_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "refresh_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "external_linkedin_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d02c222f01221c03ba04166e7439c7697e83e8b1fec4920ea8cbe63fabf64914" +} diff --git a/.sqlx/query-f67a59f506252fa4344b7894b432711b9476003cd0873e083c101949dc0cf7c6.json b/.sqlx/query-f67a59f506252fa4344b7894b432711b9476003cd0873e083c101949dc0cf7c6.json new file mode 100644 index 00000000..df36c76a --- /dev/null +++ b/.sqlx/query-f67a59f506252fa4344b7894b432711b9476003cd0873e083c101949dc0cf7c6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE LinkedInUsers\n SET\n encrypted_access_token = $1,\n access_token_expires_at = $2,\n encrypted_refresh_token = $3,\n refresh_token_expires_at = $4,\n updated_at = NOW()\n WHERE linkedin_user_id = $5\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Timestamptz", + "Bytea", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f67a59f506252fa4344b7894b432711b9476003cd0873e083c101949dc0cf7c6" +} diff --git a/db/migrations/20260523234949_AddLinkedInUsers.down.sql b/db/migrations/20260523234949_AddLinkedInUsers.down.sql new file mode 100644 index 00000000..6038d4fd --- /dev/null +++ b/db/migrations/20260523234949_AddLinkedInUsers.down.sql @@ -0,0 +1,2 @@ +DROP TABLE LinkedInOauthStates; +DROP TABLE LinkedInUsers; diff --git a/db/migrations/20260523234949_AddLinkedInUsers.up.sql b/db/migrations/20260523234949_AddLinkedInUsers.up.sql new file mode 100644 index 00000000..4559cd44 --- /dev/null +++ b/db/migrations/20260523234949_AddLinkedInUsers.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE LinkedInUsers ( + linkedin_user_id UUID PRIMARY KEY NOT NULL, + user_id UUID REFERENCES Users (user_id) NOT NULL, + external_linkedin_id TEXT NOT NULL, + encrypted_access_token BYTEA NOT NULL, + access_token_expires_at TIMESTAMPTZ NOT NULL, + encrypted_refresh_token BYTEA NOT NULL, + refresh_token_expires_at TIMESTAMPTZ NOT NULL, + scope TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX idx_linkedin_users_external_linkedin_id ON LinkedInUsers (external_linkedin_id); +CREATE UNIQUE INDEX idx_linkedin_users_user_id ON LinkedInUsers (user_id); + +-- OAuth CSRF state tracking. Each `/admin/auth/linkedin` redirect inserts a +-- row; the callback validates the `state` query param against this table and +-- rejects states older than 10 minutes. DB-backed state instead of signed +-- cookies matches the existing LinearOauthStates pattern in this codebase and +-- avoids adding new dependencies. +CREATE TABLE LinkedInOauthStates ( + linkedin_oauth_state_id UUID PRIMARY KEY NOT NULL, + state TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Index for the callback's freshness check and any future cleanup job that +-- needs to sweep stale rows (mirrors idx_linear_oauth_states_created_at). +CREATE INDEX idx_linkedin_oauth_states_created_at ON LinkedInOauthStates (created_at); diff --git a/posts/src/blog.rs b/posts/src/blog.rs index 30ab5b9f..963a3dd2 100644 --- a/posts/src/blog.rs +++ b/posts/src/blog.rs @@ -161,6 +161,12 @@ impl BlogPosts { let posts = dir .find("**/*.md")? .filter_map(|e| e.as_file()) + .filter(|f| { + // Skip LinkedIn custom-body sibling files. + // Covers blog posts, newsletters under blog/weekly/, and any future sub-tree. + let name = f.path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + name != "linkedin.md" && !name.ends_with(".linkedin.md") + }) .map(BlogPost::from_file) .collect::>>() .wrap_err("One of the blog posts failed to parse")?; @@ -243,6 +249,10 @@ pub struct BlogFrontMatter { #[serde(default = "default_is_newsletter")] pub is_newsletter: bool, pub bsky_url: Option, + pub linkedin_url: Option, + /// Custom `LinkedIn` body. When `None`, defaults to the first paragraph + /// (or a sibling `linkedin.md` file if present). + pub linkedin_content: Option, /// When to send the newsletter. If `None` and `is_newsletter` is true, send immediately. pub newsletter_send_at: Option>, /// Buttondown email ID, populated after publishing to Buttondown. @@ -356,6 +366,8 @@ mod test { track: Track::default(), is_newsletter: false, bsky_url: None, + linkedin_url: None, + linkedin_content: None, newsletter_send_at: None, buttondown_id: None, og_image: None, @@ -406,6 +418,8 @@ mod test { atproto_uri: None, atproto_pub_cid: None, publication: "blog".to_string(), + linkedin_url: None, + linkedin_content: None, }, } } @@ -462,6 +476,27 @@ mod test { ); } + #[test] + fn frontmatter_deserializes_with_linkedin_url() { + let yaml = "title: Sample\ndate: 2026-05-23\nlinkedin_url: https://www.linkedin.com/feed/update/urn:li:share:1\n"; + let fm: BlogFrontMatter = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + fm.linkedin_url.as_deref(), + Some("https://www.linkedin.com/feed/update/urn:li:share:1") + ); + assert!(fm.linkedin_content.is_none()); + } + + #[test] + fn frontmatter_deserializes_with_linkedin_content() { + let yaml = "title: Sample\ndate: 2026-05-23\nlinkedin_content: Custom body for LinkedIn.\n"; + let fm: BlogFrontMatter = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + fm.linkedin_content.as_deref(), + Some("Custom body for LinkedIn.") + ); + } + #[test] fn blog_load_from_static_dir() { let blog = BlogPosts::from_static_dir().unwrap(); @@ -470,4 +505,16 @@ mod test { "Should have at least one blog post loaded from the blog/ directory" ); } + + #[test] + fn from_dir_skips_linkedin_md_sibling_files() { + use include_dir::{include_dir, Dir}; + // Use the real blog dir. We just ensure no parse error and that + // a hypothetical sibling file would be filtered (this exercises + // the filter on real input). + static D: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../blog"); + // Should not error even if any linkedin.md / *.linkedin.md exist + // (no .md frontmatter required for filtered files). + let _ = BlogPosts::from_dir(&D).expect("from_dir succeeds"); + } } diff --git a/posts/src/podcast.rs b/posts/src/podcast.rs index 6180cb80..d9ac4dde 100644 --- a/posts/src/podcast.rs +++ b/posts/src/podcast.rs @@ -36,6 +36,8 @@ pub struct PodcastFrontMatter { pub transcript_url: Option, /// Absolute URL of an OG image to use instead of the auto-generated branded card. pub og_image: Option, + pub linkedin_url: Option, + pub linkedin_content: Option, } impl PostedOn for PodcastFrontMatter { @@ -72,6 +74,10 @@ impl PodcastEpisodes { let episodes = dir .find("**/*.md")? .filter_map(|e| e.as_file()) + .filter(|f| { + let name = f.path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + name != "linkedin.md" && !name.ends_with(".linkedin.md") + }) .map(PodcastEpisode::from_file) .collect::>>() .wrap_err("One of the podcast episodes failed to parse")?; @@ -96,4 +102,32 @@ mod test { assert!(!ep.frontmatter.youtube_id.is_empty()); assert!(!ep.frontmatter.audio_url.is_empty()); } + + #[test] + fn frontmatter_deserializes_with_linkedin_url() { + let yaml = "title: Sample\ndate: 2026-05-23\nslug: sample\nyoutube_id: abc\naudio_url: https://x/audio.mp3\naudio_length_bytes: 1\naudio_duration: \"01:00:00\"\nlinkedin_url: https://www.linkedin.com/feed/update/urn:li:share:1\n"; + let fm: PodcastFrontMatter = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + fm.linkedin_url.as_deref(), + Some("https://www.linkedin.com/feed/update/urn:li:share:1") + ); + assert!(fm.linkedin_content.is_none()); + } + + #[test] + fn frontmatter_deserializes_with_linkedin_content() { + let yaml = "title: Sample\ndate: 2026-05-23\nslug: sample\nyoutube_id: abc\naudio_url: https://x/audio.mp3\naudio_length_bytes: 1\naudio_duration: \"01:00:00\"\nlinkedin_content: Custom body for LinkedIn.\n"; + let fm: PodcastFrontMatter = serde_yaml::from_str(yaml).unwrap(); + assert_eq!( + fm.linkedin_content.as_deref(), + Some("Custom body for LinkedIn.") + ); + } + + #[test] + fn from_dir_skips_linkedin_md_sibling_files() { + use include_dir::{include_dir, Dir}; + static D: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../podcast"); + let _ = PodcastEpisodes::from_dir(&D).expect("from_dir succeeds"); + } } diff --git a/server/src/commands/linkedin.rs b/server/src/commands/linkedin.rs new file mode 100644 index 00000000..7a9a584e --- /dev/null +++ b/server/src/commands/linkedin.rs @@ -0,0 +1,742 @@ +#![allow(clippy::doc_markdown)] + +use std::path::{Path, PathBuf}; + +use chrono::NaiveDate; +use clap::Args; +use posts::{blog::BlogFrontMatter, podcast::PodcastFrontMatter}; + +use crate::{ + linkedin::{ + compose_linkedin_body, extract_first_paragraph, linkedin_urn_to_web_url, LinkedInClient, + }, + AppConfig, +}; + +/// Cutoff date — confirm/update at PR-open time; must be the actual merge day. +const LINKEDIN_CUTOFF_DATE: &str = "2026-05-23"; + +#[derive(clap::ValueEnum, Clone, Debug)] +pub enum LinkedInKind { + Blog, + Newsletter, + Podcast, +} + +#[derive(Args, Debug)] +pub struct PublishLinkedInArgs { + #[arg(long)] + pub dir: PathBuf, + #[arg(long, value_enum)] + pub kind: LinkedInKind, +} + +fn cutoff_date() -> NaiveDate { + NaiveDate::parse_from_str(LINKEDIN_CUTOFF_DATE, "%Y-%m-%d") + .expect("LINKEDIN_CUTOFF_DATE should be valid") +} + +pub async fn publish_linkedin(args: &PublishLinkedInArgs) -> cja::Result<()> { + let app_config = AppConfig::from_env()?; + let client = LinkedInClient::from_db_env().await?; + + let paths = match args.kind { + LinkedInKind::Blog => find_blog_posts(&args.dir)?, + LinkedInKind::Newsletter => find_newsletters(&args.dir)?, + LinkedInKind::Podcast => find_podcast_episodes(&args.dir)?, + }; + + if paths.is_empty() { + println!("No unpublished items found in {}", args.dir.display()); + return Ok(()); + } + + println!("Found {} item(s) to publish", paths.len()); + + let mut failures: Vec<(PathBuf, cja::color_eyre::Report)> = Vec::new(); + let mut published = 0usize; + for path in &paths { + let result = match args.kind { + LinkedInKind::Blog => publish_one_blog(path, &client, &app_config).await, + LinkedInKind::Newsletter => publish_one_newsletter(path, &client, &app_config).await, + LinkedInKind::Podcast => publish_one_podcast(path, &client, &app_config).await, + }; + match result { + Ok(true) => published += 1, + Ok(false) => {} + Err(e) => { + eprintln!("Failed to publish {}: {e}", path.display()); + failures.push((path.clone(), e)); + } + } + } + + println!( + "Done. Published: {published}, skipped: {}, failed: {}", + paths.len() - published - failures.len(), + failures.len() + ); + + if !failures.is_empty() { + return Err(cja::color_eyre::eyre::eyre!( + "{} item(s) failed to publish (see logs above); next run will retry", + failures.len() + )); + } + + Ok(()) +} + +// ==================== Scanners ==================== + +fn find_blog_posts(dir: &Path) -> cja::Result> { + let mut paths = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| { + cja::color_eyre::eyre::eyre!("Failed to read directory {}: {}", dir.display(), e) + })?; + + for entry in entries { + let entry = entry?; + let p = entry.path(); + if !p.is_dir() { + continue; + } + let index = p.join("index.md"); + if !index.is_file() { + continue; + } + match classify_blog(&index) { + Ok(Some(())) => paths.push(index), + Ok(None) => {} + Err(e) => eprintln!("Failed to classify {}: {e}", index.display()), + } + } + paths.sort(); + Ok(paths) +} + +fn find_newsletters(dir: &Path) -> cja::Result> { + let mut paths = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| { + cja::color_eyre::eyre::eyre!("Failed to read directory {}: {}", dir.display(), e) + })?; + + for entry in entries { + let entry = entry?; + let p = entry.path(); + if !p.is_dir() { + continue; + } + let index = p.join("index.md"); + if !index.is_file() { + continue; + } + match classify_newsletter(&index) { + Ok(Some(())) => paths.push(index), + Ok(None) => {} + Err(e) => eprintln!("Failed to classify {}: {e}", index.display()), + } + } + paths.sort(); + Ok(paths) +} + +fn find_podcast_episodes(dir: &Path) -> cja::Result> { + let mut paths = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| { + cja::color_eyre::eyre::eyre!("Failed to read directory {}: {}", dir.display(), e) + })?; + + for entry in entries { + let entry = entry?; + let p = entry.path(); + if !p.is_file() { + continue; + } + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Skip sibling LinkedIn custom-body files. + if name == "linkedin.md" || name.ends_with(".linkedin.md") { + continue; + } + if p.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + match classify_podcast(&p) { + Ok(Some(())) => paths.push(p), + Ok(None) => {} + Err(e) => eprintln!("Failed to classify {}: {e}", p.display()), + } + } + paths.sort(); + Ok(paths) +} + +// ==================== Classifiers ==================== + +/// `Ok(Some(()))` = eligible; `Ok(None)` = skip; `Err` = parse/read failure. +/// +/// The authoritative idempotency check is `fm.linkedin_url.is_some()` — +/// a raw `content.contains("linkedin_url:")` would false-positive against +/// any body text that mentions the literal string (e.g., a future post +/// about POSSE syndication). +fn classify_blog(path: &Path) -> cja::Result> { + let content = read_to_string(path)?; + let (fm, _) = parse_blog_frontmatter(&content)?; + if fm.is_newsletter { + return Ok(None); + } + if fm.linkedin_url.is_some() { + return Ok(None); + } + if fm.date < cutoff_date() { + return Ok(None); + } + Ok(Some(())) +} + +fn classify_newsletter(path: &Path) -> cja::Result> { + let content = read_to_string(path)?; + let (fm, _) = parse_blog_frontmatter(&content)?; + if !fm.is_newsletter { + return Ok(None); + } + if fm.linkedin_url.is_some() { + return Ok(None); + } + if fm.date < cutoff_date() { + return Ok(None); + } + Ok(Some(())) +} + +fn classify_podcast(path: &Path) -> cja::Result> { + let content = read_to_string(path)?; + let (fm, _) = parse_podcast_frontmatter(&content)?; + if fm.linkedin_url.is_some() { + return Ok(None); + } + if fm.date < cutoff_date() { + return Ok(None); + } + Ok(Some(())) +} + +// ==================== Publishers ==================== + +/// What kind of frontmatter a path uses — used by the race-guard re-read +/// in `publish_and_write` to re-parse the file and authoritatively check +/// `fm.linkedin_url.is_some()`. +#[derive(Copy, Clone)] +enum FrontmatterShape { + Blog, + Podcast, +} + +async fn publish_one_blog( + path: &Path, + client: &LinkedInClient, + app_config: &AppConfig, +) -> cja::Result { + let content = read_to_string(path)?; + let (fm, body) = parse_blog_frontmatter(&content)?; + if fm.linkedin_url.is_some() { + println!("Already syndicated, skipping: {}", path.display()); + return Ok(false); + } + let custom = read_custom_body(path, fm.linkedin_content.as_deref()); + let first_para = match custom { + Some(s) => s, + None => extract_first_paragraph(&body), + }; + let url = canonical_url_for_blog(path, app_config)?; + publish_and_write( + path, + client, + &fm.title, + &first_para, + &url, + FrontmatterShape::Blog, + ) + .await +} + +async fn publish_one_newsletter( + path: &Path, + client: &LinkedInClient, + app_config: &AppConfig, +) -> cja::Result { + let content = read_to_string(path)?; + let (fm, body) = parse_blog_frontmatter(&content)?; + if fm.linkedin_url.is_some() { + println!("Already syndicated, skipping: {}", path.display()); + return Ok(false); + } + let custom = read_custom_body(path, fm.linkedin_content.as_deref()); + let first_para = match custom { + Some(s) => s, + None => extract_first_paragraph(&body), + }; + let url = canonical_url_for_newsletter(path, app_config)?; + publish_and_write( + path, + client, + &fm.title, + &first_para, + &url, + FrontmatterShape::Blog, + ) + .await +} + +async fn publish_one_podcast( + path: &Path, + client: &LinkedInClient, + app_config: &AppConfig, +) -> cja::Result { + let content = read_to_string(path)?; + let (fm, body) = parse_podcast_frontmatter(&content)?; + if fm.linkedin_url.is_some() { + println!("Already syndicated, skipping: {}", path.display()); + return Ok(false); + } + let custom = read_custom_body(path, fm.linkedin_content.as_deref()); + let first_para = match custom { + Some(s) => s, + None => extract_first_paragraph(&body), + }; + let url = canonical_url_for_podcast(&fm, app_config); + publish_and_write( + path, + client, + &fm.title, + &first_para, + &url, + FrontmatterShape::Podcast, + ) + .await +} + +/// Re-parse the file's frontmatter to see if `linkedin_url` has been set since +/// our scan. Parses authoritatively — not a substring scan — so body text +/// mentioning the literal `linkedin_url:` doesn't trigger a false race. +fn frontmatter_has_linkedin_url(content: &str, shape: FrontmatterShape) -> cja::Result { + match shape { + FrontmatterShape::Blog => { + let (fm, _) = parse_blog_frontmatter(content)?; + Ok(fm.linkedin_url.is_some()) + } + FrontmatterShape::Podcast => { + let (fm, _) = parse_podcast_frontmatter(content)?; + Ok(fm.linkedin_url.is_some()) + } + } +} + +async fn publish_and_write( + path: &Path, + client: &LinkedInClient, + title: &str, + body: &str, + canonical_url: &str, + shape: FrontmatterShape, +) -> cja::Result { + let commentary = compose_linkedin_body(body, title, canonical_url); + println!("Publishing to LinkedIn: {title}"); + let resp = client.create_text_post(&commentary).await?; + let web_url = linkedin_urn_to_web_url(&resp.urn)?; + println!("Published to LinkedIn: {web_url}"); + + // Re-read the file from disk and confirm linkedin_url is still absent in + // the frontmatter. The workflow's `concurrency: linkedin-publish` group + // means this should be unreachable in practice; if we ever hit it, the + // LinkedIn post we just created is orphaned (no linkedin_url on disk + // pointing at it). Log at error! with the URN/URL so it's recoverable + // from workflow logs. + let latest = read_to_string(path)?; + if frontmatter_has_linkedin_url(&latest, shape)? { + tracing::error!( + urn = %resp.urn, + web_url = %web_url, + path = %path.display(), + "Race detected: {} already had linkedin_url written by another process \ + after we POSTed. The LinkedIn post we just created is ORPHANED — \ + manually delete it on LinkedIn (urn={}, url={}) or update the frontmatter.", + path.display(), + resp.urn, + web_url, + ); + return Ok(false); + } + + let updated = update_frontmatter_with_linkedin_url(&latest, &web_url)?; + std::fs::write(path, updated).map_err(|e| { + cja::color_eyre::eyre::eyre!("Failed to write file {}: {}", path.display(), e) + })?; + println!("Updated {} with linkedin_url", path.display()); + Ok(true) +} + +// ==================== Helpers ==================== + +fn read_to_string(path: &Path) -> cja::Result { + std::fs::read_to_string(path) + .map_err(|e| cja::color_eyre::eyre::eyre!("Failed to read file {}: {}", path.display(), e)) +} + +/// Sibling-file path for a custom LinkedIn body. +/// - `blog//index.md` and `blog/weekly//index.md` → `/linkedin.md` +/// - `podcast/.md` → `/.linkedin.md` +fn sibling_linkedin_path_for(post_path: &Path) -> PathBuf { + let file_name = post_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let parent = post_path.parent().unwrap_or_else(|| Path::new(".")); + if file_name == "index.md" { + parent.join("linkedin.md") + } else if let Some(stem) = post_path.file_stem().and_then(|s| s.to_str()) { + parent.join(format!("{stem}.linkedin.md")) + } else { + parent.join("linkedin.md") + } +} + +/// First non-empty source wins, in this order: +/// 1. Sibling `linkedin.md` file (after `trim()`) +/// 2. `frontmatter_linkedin_content` field (after `trim()`) +/// +/// Returns `None` to signal "fall back to first-paragraph extraction". +fn read_custom_body( + post_path: &Path, + frontmatter_linkedin_content: Option<&str>, +) -> Option { + let sibling = sibling_linkedin_path_for(post_path); + if let Ok(s) = std::fs::read_to_string(&sibling) { + if !s.trim().is_empty() { + return Some(s); + } + } + if let Some(s) = frontmatter_linkedin_content { + if !s.trim().is_empty() { + return Some(s.to_string()); + } + } + None +} + +/// Append `linkedin_url: ` into the YAML frontmatter block. Returns +/// `Err` on malformed frontmatter or if `linkedin_url:` is already present +/// (defense-in-depth: never silently no-op). +fn update_frontmatter_with_linkedin_url(content: &str, url: &str) -> cja::Result { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return Err(cja::color_eyre::eyre::eyre!( + "Missing frontmatter delimiter" + )); + } + let rest = &trimmed[3..]; + let Some(end_idx) = rest.find("\n---") else { + return Err(cja::color_eyre::eyre::eyre!( + "Missing closing frontmatter delimiter" + )); + }; + let yaml = &rest[..end_idx]; + let body = &rest[end_idx + 4..]; + + if yaml.contains("linkedin_url:") { + return Err(cja::color_eyre::eyre::eyre!( + "linkedin_url already present in frontmatter" + )); + } + + let updated_yaml = format!("{}\nlinkedin_url: {url}", yaml.trim_end()); + Ok(format!("---\n{updated_yaml}\n---{body}")) +} + +fn parse_blog_frontmatter(content: &str) -> cja::Result<(BlogFrontMatter, String)> { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return Err(cja::color_eyre::eyre::eyre!( + "Missing frontmatter delimiter" + )); + } + let rest = &trimmed[3..]; + let Some(end_idx) = rest.find("\n---") else { + return Err(cja::color_eyre::eyre::eyre!( + "Missing closing frontmatter delimiter" + )); + }; + let yaml = rest[..end_idx].trim(); + let body = &rest[end_idx + 4..]; + let fm: BlogFrontMatter = serde_yaml::from_str(yaml) + .map_err(|e| cja::color_eyre::eyre::eyre!("Invalid YAML: {e}"))?; + Ok((fm, body.to_string())) +} + +fn parse_podcast_frontmatter(content: &str) -> cja::Result<(PodcastFrontMatter, String)> { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return Err(cja::color_eyre::eyre::eyre!( + "Missing frontmatter delimiter" + )); + } + let rest = &trimmed[3..]; + let Some(end_idx) = rest.find("\n---") else { + return Err(cja::color_eyre::eyre::eyre!( + "Missing closing frontmatter delimiter" + )); + }; + let yaml = rest[..end_idx].trim(); + let body = &rest[end_idx + 4..]; + let fm: PodcastFrontMatter = serde_yaml::from_str(yaml) + .map_err(|e| cja::color_eyre::eyre::eyre!("Invalid YAML: {e}"))?; + Ok((fm, body.to_string())) +} + +// ==================== Canonical URLs ==================== + +fn canonical_url_for_blog(post_path: &Path, app_config: &AppConfig) -> cja::Result { + // blog//index.md → /posts// + let slug = post_path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .ok_or_else(|| { + cja::color_eyre::eyre::eyre!( + "Could not derive blog slug from path: {}", + post_path.display() + ) + })?; + Ok(app_config.app_url(&format!("/posts/{slug}/"))) +} + +fn canonical_url_for_newsletter(post_path: &Path, app_config: &AppConfig) -> cja::Result { + // blog/weekly//index.md → /posts/weekly// + let dir = post_path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .ok_or_else(|| { + cja::color_eyre::eyre::eyre!( + "Could not derive newsletter date from path: {}", + post_path.display() + ) + })?; + Ok(app_config.app_url(&format!("/posts/weekly/{dir}/"))) +} + +fn canonical_url_for_podcast(fm: &PodcastFrontMatter, app_config: &AppConfig) -> String { + // Use the frontmatter slug, NOT the filename stem. + app_config.app_url(&format!("/podcast/{}", fm.slug)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> AppConfig { + AppConfig { + base_url: url::Url::parse("https://coreyja.com").unwrap(), + imgproxy_url: None, + } + } + + fn write_file(dir: &Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).expect("mkdir"); + } + std::fs::write(&p, body).expect("write"); + p + } + + // ==================== Classifiers ==================== + + #[test] + fn classify_blog_skips_already_published() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "index.md", + "---\ntitle: T\ndate: 2026-05-25\nlinkedin_url: https://x\n---\n\nbody\n", + ); + assert!(classify_blog(&p).unwrap().is_none()); + } + + #[test] + fn classify_blog_skips_before_cutoff() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "index.md", + "---\ntitle: T\ndate: 2024-01-01\n---\n\nbody\n", + ); + assert!(classify_blog(&p).unwrap().is_none()); + } + + #[test] + fn classify_blog_skips_newsletters() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "index.md", + "---\ntitle: T\ndate: 2026-05-25\nis_newsletter: true\n---\n\nbody\n", + ); + assert!(classify_blog(&p).unwrap().is_none()); + } + + #[test] + fn classify_blog_accepts_cutoff_day_boundary() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "index.md", + "---\ntitle: T\ndate: 2026-05-23\n---\n\nbody\n", + ); + assert!(classify_blog(&p).unwrap().is_some()); + } + + #[test] + fn classify_newsletter_requires_is_newsletter_flag() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "index.md", + "---\ntitle: T\ndate: 2026-05-25\n---\n\nbody\n", + ); + assert!(classify_newsletter(&p).unwrap().is_none()); + } + + #[test] + fn classify_podcast_accepts_eligible() { + let tmp = tempfile::tempdir().unwrap(); + let p = write_file( + tmp.path(), + "ep5.md", + "---\ntitle: T\ndate: 2026-05-25\nslug: ep5\nyoutube_id: abc\naudio_url: https://x/audio.mp3\naudio_length_bytes: 1\naudio_duration: \"01:00:00\"\n---\n\nbody\n", + ); + assert!(classify_podcast(&p).unwrap().is_some()); + } + + // ==================== update_frontmatter_with_linkedin_url ==================== + + #[test] + fn update_frontmatter_with_linkedin_url_basic() { + let content = "---\ntitle: T\ndate: 2026-05-25\n---\n\nBody.\n"; + let updated = update_frontmatter_with_linkedin_url(content, "https://x").unwrap(); + assert!(updated.contains("linkedin_url: https://x")); + assert!(updated.contains("Body.")); + } + + #[test] + fn update_frontmatter_with_linkedin_url_returns_err_on_malformed() { + let bad = "---\ntitle: T\ndate: 2026-05-25\n\nNo closing.\n"; + assert!(update_frontmatter_with_linkedin_url(bad, "https://x").is_err()); + } + + #[test] + fn update_frontmatter_with_linkedin_url_returns_err_if_already_present() { + let content = + "---\ntitle: T\ndate: 2026-05-25\nlinkedin_url: https://existing\n---\n\nBody.\n"; + assert!(update_frontmatter_with_linkedin_url(content, "https://new").is_err()); + } + + #[test] + fn update_frontmatter_preserves_other_syndication_urls() { + let content = + "---\ntitle: T\ndate: 2026-05-25\nbsky_url: https://bsky.app/x\n---\n\nBody.\n"; + let updated = update_frontmatter_with_linkedin_url(content, "https://li").unwrap(); + assert!(updated.contains("bsky_url: https://bsky.app/x")); + assert!(updated.contains("linkedin_url: https://li")); + } + + // ==================== Canonical URLs ==================== + + #[test] + fn canonical_url_for_blog_basic() { + let cfg = test_config(); + let p = PathBuf::from("blog/foo/index.md"); + assert_eq!( + canonical_url_for_blog(&p, &cfg).unwrap(), + "https://coreyja.com/posts/foo/" + ); + } + + #[test] + fn canonical_url_for_newsletter_basic() { + let cfg = test_config(); + let p = PathBuf::from("blog/weekly/20260601/index.md"); + assert_eq!( + canonical_url_for_newsletter(&p, &cfg).unwrap(), + "https://coreyja.com/posts/weekly/20260601/" + ); + } + + #[test] + fn canonical_url_for_podcast_uses_slug_not_filename() { + let cfg = test_config(); + let fm = PodcastFrontMatter { + title: "T".into(), + date: NaiveDate::from_ymd_opt(2026, 5, 25).unwrap(), + slug: "ep5".into(), + youtube_id: "abc".into(), + youtube_url: None, + audio_url: "https://x/audio.mp3".into(), + audio_length_bytes: 1, + audio_duration: "01:00:00".into(), + transcript_url: None, + og_image: None, + linkedin_url: None, + linkedin_content: None, + }; + assert_eq!( + canonical_url_for_podcast(&fm, &cfg), + "https://coreyja.com/podcast/ep5" + ); + } + + // ==================== Sibling paths ==================== + + #[test] + fn sibling_linkedin_path_for_blog() { + let p = PathBuf::from("blog/foo/index.md"); + assert_eq!( + sibling_linkedin_path_for(&p), + PathBuf::from("blog/foo/linkedin.md") + ); + } + + #[test] + fn sibling_linkedin_path_for_podcast() { + let p = PathBuf::from("podcast/ep5.md"); + assert_eq!( + sibling_linkedin_path_for(&p), + PathBuf::from("podcast/ep5.linkedin.md") + ); + } + + // ==================== Body source ordering ==================== + + #[test] + fn body_source_prefers_sibling_file_over_frontmatter() { + let tmp = tempfile::tempdir().unwrap(); + let post = write_file(tmp.path(), "index.md", "stub"); + let _sibling = write_file(tmp.path(), "linkedin.md", "From sibling file.\n"); + let out = read_custom_body(&post, Some("from frontmatter")).unwrap(); + assert_eq!(out.trim(), "From sibling file."); + } + + #[test] + fn body_source_falls_back_to_first_paragraph_when_sibling_empty() { + let tmp = tempfile::tempdir().unwrap(); + let post = write_file(tmp.path(), "index.md", "stub"); + let _sibling = write_file(tmp.path(), "linkedin.md", " \n"); + let out = read_custom_body(&post, None); + assert!(out.is_none()); + } + + #[test] + fn body_source_skips_empty_linkedin_content_field() { + let tmp = tempfile::tempdir().unwrap(); + let post = write_file(tmp.path(), "index.md", "stub"); + let out = read_custom_body(&post, Some(" ")); + assert!(out.is_none()); + } +} diff --git a/server/src/commands/mod.rs b/server/src/commands/mod.rs index 850fa71d..3b75db7a 100644 --- a/server/src/commands/mod.rs +++ b/server/src/commands/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod bluesky; pub(crate) mod buttondown; pub(crate) mod frontmatter; pub(crate) mod info; +pub(crate) mod linkedin; pub(crate) mod standard_site; pub(crate) mod validate; @@ -21,6 +22,9 @@ pub(crate) enum Command { /// Manage standard.site publications and documents on the PDS #[command(subcommand)] PublishStandardSite(standard_site::StandardSiteCommand), + /// Publish to `LinkedIn` + #[command(name = "publish-linkedin", about = "Publish to LinkedIn")] + PublishLinkedin(linkedin::PublishLinkedInArgs), } impl Command { @@ -32,6 +36,7 @@ impl Command { Command::PublishButtondown(args) => buttondown::publish_buttondown(args).await, Command::PublishBluesky(args) => bluesky::publish_bluesky(args).await, Command::PublishStandardSite(cmd) => standard_site::run(cmd).await, + Command::PublishLinkedin(args) => linkedin::publish_linkedin(args).await, } } } diff --git a/server/src/commands/standard_site.rs b/server/src/commands/standard_site.rs index e5e7adbe..cc7d99e7 100644 --- a/server/src/commands/standard_site.rs +++ b/server/src/commands/standard_site.rs @@ -622,6 +622,8 @@ mod tests { atproto_uri: None, atproto_pub_cid: None, publication: "blog".to_string(), + linkedin_url: None, + linkedin_content: None, } } diff --git a/server/src/cron.rs b/server/src/cron.rs index 2b988744..0c24512c 100644 --- a/server/src/cron.rs +++ b/server/src/cron.rs @@ -4,8 +4,8 @@ use cja::cron::{CronRegistry, Worker}; use crate::{ jobs::{ - refresh_discord::RefreshDiscordChannels, sponsors::RefreshSponsors, - youtube_videos::RefreshVideos, + refresh_discord::RefreshDiscordChannels, refresh_linkedin_token::RefreshLinkedInToken, + sponsors::RefreshSponsors, youtube_videos::RefreshVideos, }, state::AppState, }; @@ -20,6 +20,7 @@ pub(crate) fn cron_registry() -> CronRegistry { registry.register_job(RefreshSponsors, None, one_hour()); registry.register_job(RefreshVideos, None, one_hour()); registry.register_job(RefreshDiscordChannels, None, one_hour()); + registry.register_job(RefreshLinkedInToken, None, Duration::from_hours(6)); registry } diff --git a/server/src/http_server/admin/linkedin_auth.rs b/server/src/http_server/admin/linkedin_auth.rs new file mode 100644 index 00000000..4e30262f --- /dev/null +++ b/server/src/http_server/admin/linkedin_auth.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; + +use axum::{ + extract::{Query, State}, + response::{IntoResponse, Redirect}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{http_server::auth::session::AdminUser, state::AppState}; + +const LINKEDIN_AUTH_URL: &str = "https://www.linkedin.com/oauth/v2/authorization"; +const LINKEDIN_TOKEN_URL: &str = "https://www.linkedin.com/oauth/v2/accessToken"; +const LINKEDIN_USERINFO_URL: &str = "https://api.linkedin.com/v2/userinfo"; + +/// Personal-profile posting scopes: +/// - `openid` + `profile` give us the `sub` claim via /v2/userinfo +/// - `w_member_social` is the narrowest write scope to post as the user +const LINKEDIN_SCOPE: &str = "openid profile w_member_social"; + +/// CSRF state lifetime. After this many minutes the callback rejects the row +/// rather than honoring it. Keeps the security guarantee comparable to a +/// short-lived signed cookie. +const OAUTH_STATE_TTL_MINUTES: i64 = 10; + +pub(crate) async fn linkedin_auth( + State(app_state): State, + _: AdminUser, +) -> Result { + let Some(linkedin) = app_state.linkedin.as_ref() else { + return Err( + "LinkedIn not configured. Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET env vars and restart." + .to_string(), + ); + }; + + // DB-backed CSRF state — same pattern as LinearOauthStates. Each redirect + // inserts a fresh row; the callback validates the `state` query param + // against it and enforces a 10-minute freshness window so a stolen/leaked + // state value can't be replayed indefinitely. + let state_id = Uuid::new_v4(); + let state_value = Uuid::new_v4().to_string(); + + sqlx::query!( + r#" + INSERT INTO LinkedInOauthStates (linkedin_oauth_state_id, state) + VALUES ($1, $2) + "#, + state_id, + state_value, + ) + .execute(&app_state.db) + .await + .map_err(|e| format!("Failed to record OAuth state: {e}"))?; + + let redirect_uri = app_state.app.app_url("/admin/auth/linkedin/callback"); + let auth_url = url::Url::parse_with_params( + LINKEDIN_AUTH_URL, + &[ + ("response_type", "code"), + ("client_id", &linkedin.client_id), + ("redirect_uri", &redirect_uri), + ("scope", LINKEDIN_SCOPE), + ("state", &state_value), + ], + ) + .map_err(|e| format!("Failed to build LinkedIn auth URL: {e}"))? + .to_string(); + + Ok(Redirect::to(&auth_url)) +} + +#[derive(Debug, Deserialize)] +struct LinkedInTokenResponse { + access_token: String, + expires_in: i64, + refresh_token: String, + refresh_token_expires_in: i64, + scope: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct LinkedInUserInfo { + sub: String, +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn linkedin_auth_callback( + State(app_state): State, + Query(params): Query>, + admin: AdminUser, +) -> Result { + let Some(linkedin) = app_state.linkedin.as_ref() else { + return Err("LinkedIn not configured.".to_string()); + }; + + let code = params + .get("code") + .ok_or_else(|| "No code in callback query".to_string())?; + let state = params + .get("state") + .ok_or_else(|| "No state in callback query".to_string())?; + + // Validate state against the DB, requiring the row to be no older than + // OAUTH_STATE_TTL_MINUTES. On success we delete the row so it can't be + // replayed. Also opportunistically sweep any other stale rows so the + // table doesn't grow unboundedly from abandoned "Connect LinkedIn" clicks. + let ttl_cutoff = chrono::Utc::now() - chrono::Duration::minutes(OAUTH_STATE_TTL_MINUTES); + let matched = sqlx::query!( + "SELECT linkedin_oauth_state_id FROM LinkedInOauthStates \ + WHERE state = $1 AND created_at >= $2", + state, + ttl_cutoff, + ) + .fetch_optional(&app_state.db) + .await + .map_err(|e| format!("Failed to look up OAuth state: {e}"))?; + + let Some(matched) = matched else { + return Err("Invalid or expired OAuth state".to_string()); + }; + + sqlx::query!( + "DELETE FROM LinkedInOauthStates \ + WHERE linkedin_oauth_state_id = $1 OR created_at < $2", + matched.linkedin_oauth_state_id, + ttl_cutoff, + ) + .execute(&app_state.db) + .await + .map_err(|e| format!("Failed to clear OAuth state: {e}"))?; + + let redirect_uri = app_state.app.app_url("/admin/auth/linkedin/callback"); + let http = reqwest::Client::new(); + let token_resp = http + .post(LINKEDIN_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &redirect_uri), + ("client_id", &linkedin.client_id), + ("client_secret", &linkedin.client_secret), + ]) + .send() + .await + .map_err(|e| format!("Token exchange request failed: {e}"))?; + + if !token_resp.status().is_success() { + let status = token_resp.status(); + let body = token_resp.text().await.unwrap_or_default(); + return Err(format!("LinkedIn token exchange failed ({status}): {body}")); + } + + let token_data: LinkedInTokenResponse = token_resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {e}"))?; + + let userinfo_resp = http + .get(LINKEDIN_USERINFO_URL) + .bearer_auth(&token_data.access_token) + .send() + .await + .map_err(|e| format!("Userinfo request failed: {e}"))?; + + if !userinfo_resp.status().is_success() { + let status = userinfo_resp.status(); + let body = userinfo_resp.text().await.unwrap_or_default(); + return Err(format!("LinkedIn userinfo failed ({status}): {body}")); + } + + let user_info: LinkedInUserInfo = userinfo_resp + .json() + .await + .map_err(|e| format!("Failed to parse userinfo: {e}"))?; + + let encrypted_access = app_state + .encrypt_config + .encrypt(&token_data.access_token) + .map_err(|e| format!("Failed to encrypt access token: {e}"))?; + let encrypted_refresh = app_state + .encrypt_config + .encrypt(&token_data.refresh_token) + .map_err(|e| format!("Failed to encrypt refresh token: {e}"))?; + + let access_expires_at = chrono::Utc::now() + chrono::Duration::seconds(token_data.expires_in); + let refresh_expires_at = + chrono::Utc::now() + chrono::Duration::seconds(token_data.refresh_token_expires_in); + + sqlx::query!( + r#" + INSERT INTO LinkedInUsers + (linkedin_user_id, user_id, external_linkedin_id, + encrypted_access_token, access_token_expires_at, + encrypted_refresh_token, refresh_token_expires_at, scope) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id) DO UPDATE SET + external_linkedin_id = EXCLUDED.external_linkedin_id, + encrypted_access_token = EXCLUDED.encrypted_access_token, + access_token_expires_at = EXCLUDED.access_token_expires_at, + encrypted_refresh_token = EXCLUDED.encrypted_refresh_token, + refresh_token_expires_at = EXCLUDED.refresh_token_expires_at, + scope = EXCLUDED.scope, + updated_at = NOW() + "#, + Uuid::new_v4(), + admin.user_id, + user_info.sub, + encrypted_access, + access_expires_at, + encrypted_refresh, + refresh_expires_at, + token_data.scope, + ) + .execute(&app_state.db) + .await + .map_err(|e| format!("Failed to persist LinkedIn user: {e}"))?; + + Ok(Redirect::to("/admin")) +} diff --git a/server/src/http_server/admin/mod.rs b/server/src/http_server/admin/mod.rs index 380396aa..e9511847 100644 --- a/server/src/http_server/admin/mod.rs +++ b/server/src/http_server/admin/mod.rs @@ -14,11 +14,13 @@ use super::{ pub(crate) mod auth; pub(crate) mod crons; pub(crate) mod job_routes; +pub(crate) mod linkedin_auth; pub(crate) mod memories; pub(crate) mod persona; pub(crate) mod threads; pub(crate) mod tool_suggestions; +#[allow(clippy::too_many_lines)] pub(crate) async fn dashboard( admin: AdminUser, State(app_state): State, @@ -42,6 +44,17 @@ pub(crate) async fn dashboard( .fetch_optional(&app_state.db) .await?; + let linkedin_user = sqlx::query!( + " + SELECT linkedin_user_id, external_linkedin_id, access_token_expires_at, + refresh_token_expires_at, created_at, updated_at + FROM LinkedInUsers + WHERE user_id = $1", + admin.user_id + ) + .fetch_optional(&app_state.db) + .await?; + let last_refresh_ats = sqlx::query!("SELECT * FROM LastRefreshAts") .fetch_all(&app_state.db) .await?; @@ -104,6 +117,27 @@ pub(crate) async fn dashboard( a href="/admin/auth/google" { "Login now" } } + h3 class="py-2 text-lg" { "LinkedIn Auth Status" } + @if let Some(li) = linkedin_user { + p { "Local LinkedIn User ID: " (li.linkedin_user_id) } + p { "External LinkedIn ID: " (li.external_linkedin_id) } + p { "Access Token Expires: " (Timestamp(li.access_token_expires_at)) } + p { "Refresh Token Expires: " (Timestamp(li.refresh_token_expires_at)) } + @if li.refresh_token_expires_at < chrono::Utc::now() + chrono::Duration::days(30) { + p class="text-red-600" { + "⚠ Re-authorize required soon — refresh token expires within 30 days." + } + a href="/admin/auth/linkedin" class="text-blue-500 hover:underline" { "Re-authenticate LinkedIn" } + } @else { + a href="/admin/auth/linkedin" class="text-blue-500 hover:underline" { "Re-authenticate LinkedIn" } + } + } @else { + p { "No LinkedIn user connected" } + a href="/admin/auth/linkedin" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded inline-block" { + "Connect LinkedIn" + } + } + h3 class="py-2 text-lg" { "Linear Integration" } @if let Some(installation) = linear_installation { p { "Linear Workspace ID: " (installation.external_workspace_id) } diff --git a/server/src/http_server/routes.rs b/server/src/http_server/routes.rs index 0d3d0880..c95391d5 100644 --- a/server/src/http_server/routes.rs +++ b/server/src/http_server/routes.rs @@ -99,6 +99,14 @@ pub(crate) fn make_router() -> Router { "/admin/auth/google/callback", get(admin::auth::google_auth_callback), ) + .route( + "/admin/auth/linkedin", + get(admin::linkedin_auth::linkedin_auth), + ) + .route( + "/admin/auth/linkedin/callback", + get(admin::linkedin_auth::linkedin_auth_callback), + ) .route( "/admin/jobs/refresh_youtube", post(admin::job_routes::refresh_youtube), diff --git a/server/src/http_server/test_helpers.rs b/server/src/http_server/test_helpers.rs index ae6ee6d1..2515c44b 100644 --- a/server/src/http_server/test_helpers.rs +++ b/server/src/http_server/test_helpers.rs @@ -60,6 +60,7 @@ pub async fn create_test_app_with_pool(pool: PgPool) -> Router { encrypt_config: crate::encrypt::Config::from_env().unwrap(), posthog_key: None, discord: crate::discord::DiscordClient::for_testing(), + linkedin: None, }; crate::http_server::routes::make_router().with_state(state) diff --git a/server/src/jobs/mod.rs b/server/src/jobs/mod.rs index c710d4a4..05a4fcea 100644 --- a/server/src/jobs/mod.rs +++ b/server/src/jobs/mod.rs @@ -14,6 +14,7 @@ pub mod discord_message_processor; pub mod discord_thread_create_processor; pub mod linear_webhook_processor; pub mod refresh_discord; +pub mod refresh_linkedin_token; pub mod sponsors; pub mod thread_processor; pub mod youtube_videos; @@ -24,6 +25,7 @@ impl_job_registry!( RefreshVideos, bytes_discord_posts::PostByteSubmission, refresh_discord::RefreshDiscordChannels, + refresh_linkedin_token::RefreshLinkedInToken, ProcessThreadStep, ProcessDiscordMessage, ProcessDiscordThreadCreate, diff --git a/server/src/jobs/refresh_linkedin_token.rs b/server/src/jobs/refresh_linkedin_token.rs new file mode 100644 index 00000000..a35c6b2c --- /dev/null +++ b/server/src/jobs/refresh_linkedin_token.rs @@ -0,0 +1,70 @@ +use cja::jobs::Job; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshLinkedInToken; + +#[async_trait::async_trait] +impl Job for RefreshLinkedInToken { + const NAME: &'static str = "RefreshLinkedInToken"; + + async fn run(&self, app_state: AppState) -> cja::Result<()> { + // LinkedIn token expiry is expected operational state, not exceptional. + // Propagating Err crashes the server via cja::cron Worker; log + Ok. + if let Err(e) = do_refresh(&app_state).await { + tracing::error!("LinkedIn token refresh failed: {:#}", e); + } + Ok(()) + } +} + +async fn do_refresh(app_state: &AppState) -> cja::Result<()> { + let Some(linkedin_config) = app_state.linkedin.as_ref() else { + tracing::info!("LinkedIn not configured (no LINKEDIN_CLIENT_ID/SECRET); skipping refresh"); + return Ok(()); + }; + + let row = sqlx::query_as!( + crate::linkedin::LinkedInUserRow, + r#" + SELECT + linkedin_user_id, + encrypted_access_token, + encrypted_refresh_token, + access_token_expires_at, + refresh_token_expires_at, + external_linkedin_id + FROM LinkedInUsers + LIMIT 1 + "# + ) + .fetch_optional(&app_state.db) + .await?; + + let Some(row) = row else { + tracing::info!("No LinkedInUsers row yet; admin must complete /admin/auth/linkedin"); + return Ok(()); + }; + + if row.refresh_token_expires_at < chrono::Utc::now() + chrono::Duration::days(30) { + tracing::warn!( + expires_at = %row.refresh_token_expires_at, + "LinkedIn refresh token expires soon — re-authorize via /admin/auth/linkedin" + ); + } + + if row.access_token_expires_at < chrono::Utc::now() + chrono::Duration::days(7) { + crate::linkedin::refresh_linkedin_token( + &app_state.db, + &app_state.encrypt_config, + linkedin_config, + &row, + ) + .await?; + tracing::info!("Refreshed LinkedIn access token"); + } + + Ok(()) +} diff --git a/server/src/linkedin.rs b/server/src/linkedin.rs new file mode 100644 index 00000000..9fe16e4d --- /dev/null +++ b/server/src/linkedin.rs @@ -0,0 +1,525 @@ +#![allow(clippy::doc_markdown)] + +use cja::color_eyre::eyre::Context; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::encrypt; + +/// LinkedIn REST API version pin. Versions deprecate ~12 months after release. +/// Update at PR-open time to a version released within the last 6 months. +/// See +pub const LINKEDIN_VERSION_HEADER: &str = "202602"; + +#[derive(Debug, Clone)] +pub struct LinkedInConfig { + pub client_id: String, + pub client_secret: String, +} + +impl LinkedInConfig { + pub fn from_env() -> cja::Result { + let client_id = + std::env::var("LINKEDIN_CLIENT_ID").context("LINKEDIN_CLIENT_ID env var missing")?; + let client_secret = std::env::var("LINKEDIN_CLIENT_SECRET") + .context("LINKEDIN_CLIENT_SECRET env var missing")?; + Ok(Self { + client_id, + client_secret, + }) + } + + pub fn from_env_optional() -> cja::Result> { + let id = std::env::var("LINKEDIN_CLIENT_ID").ok(); + let secret = std::env::var("LINKEDIN_CLIENT_SECRET").ok(); + match (id, secret) { + (None, None) => Ok(None), + (Some(client_id), Some(client_secret)) => Ok(Some(Self { + client_id, + client_secret, + })), + (Some(_), None) => Err(cja::color_eyre::eyre::eyre!( + "Partial LinkedIn config: LINKEDIN_CLIENT_ID set but LINKEDIN_CLIENT_SECRET missing" + )), + (None, Some(_)) => Err(cja::color_eyre::eyre::eyre!( + "Partial LinkedIn config: LINKEDIN_CLIENT_SECRET set but LINKEDIN_CLIENT_ID missing" + )), + } + } +} + +pub(crate) struct LinkedInUserRow { + pub linkedin_user_id: Uuid, + pub encrypted_access_token: Vec, + pub encrypted_refresh_token: Vec, + pub access_token_expires_at: chrono::DateTime, + pub refresh_token_expires_at: chrono::DateTime, + pub external_linkedin_id: String, +} + +#[derive(Debug, Deserialize)] +struct LinkedInRefreshTokenResponse { + access_token: String, + expires_in: i64, + refresh_token: String, + refresh_token_expires_in: i64, + #[allow(dead_code)] + scope: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePostResponse { + /// The post URN (e.g., `urn:li:share:1234`, `urn:li:ugcPost:abc`). + pub urn: String, +} + +#[derive(Debug, Serialize)] +struct CreatePostRequest<'a> { + author: &'a str, + commentary: &'a str, + visibility: &'static str, + distribution: Distribution, + #[serde(rename = "lifecycleState")] + lifecycle_state: &'static str, + #[serde(rename = "isReshareDisabledByAuthor")] + is_reshare_disabled_by_author: bool, +} + +#[derive(Debug, Serialize)] +struct Distribution { + #[serde(rename = "feedDistribution")] + feed_distribution: &'static str, + #[serde(rename = "targetEntities")] + target_entities: Vec, + #[serde(rename = "thirdPartyDistributionChannels")] + third_party_distribution_channels: Vec, +} + +pub struct LinkedInClient { + http: reqwest::Client, + access_token: String, + author_urn: String, +} + +impl LinkedInClient { + /// Build a client from environment + DB. Reads the single `LinkedInUsers` + /// row, refreshes the access token if it's expired (or within 5 min of + /// expiring), and returns a ready-to-use client. + pub async fn from_db_env() -> cja::Result { + let pool = db::setup_db_pool().await?; + let encrypt_config = encrypt::Config::from_env()?; + let linkedin_config = LinkedInConfig::from_env()?; + + let row = sqlx::query_as!( + LinkedInUserRow, + r#" + SELECT + linkedin_user_id, + encrypted_access_token, + encrypted_refresh_token, + access_token_expires_at, + refresh_token_expires_at, + external_linkedin_id + FROM LinkedInUsers + LIMIT 1 + "# + ) + .fetch_optional(&pool) + .await?; + + let row = row.ok_or_else(|| { + cja::color_eyre::eyre::eyre!( + "No LinkedIn user found — visit /admin/auth/linkedin to authorize" + ) + })?; + + let access_token = + if row.access_token_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) { + refresh_linkedin_token(&pool, &encrypt_config, &linkedin_config, &row).await? + } else { + encrypt_config.decrypt(&row.encrypted_access_token)? + }; + + let author_urn = format!("urn:li:person:{}", row.external_linkedin_id); + + Ok(Self { + http: reqwest::Client::new(), + access_token, + author_urn, + }) + } + + /// POST a plain-text post to LinkedIn's `/rest/posts` endpoint. Returns + /// the post URN, read from the `x-restli-id` header (with the JSON `id` + /// field as a fallback). + pub async fn create_text_post(&self, commentary: &str) -> cja::Result { + let body = CreatePostRequest { + author: &self.author_urn, + commentary, + visibility: "PUBLIC", + distribution: Distribution { + feed_distribution: "MAIN_FEED", + target_entities: Vec::new(), + third_party_distribution_channels: Vec::new(), + }, + lifecycle_state: "PUBLISHED", + is_reshare_disabled_by_author: false, + }; + + let resp = self + .http + .post("https://api.linkedin.com/rest/posts") + .bearer_auth(&self.access_token) + .header("LinkedIn-Version", LINKEDIN_VERSION_HEADER) + .header("X-Restli-Protocol-Version", "2.0.0") + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "LinkedIn /rest/posts failed ({status}): {body}" + )); + } + + let header_urn = resp + .headers() + .get("x-restli-id") + .and_then(|v| v.to_str().ok()) + .map(std::string::ToString::to_string); + + // Parse the JSON body even if we have the header — gives us a fallback + // and surfaces a malformed payload before we silently return an empty URN. + let body_text = resp.text().await.unwrap_or_default(); + let json_urn: Option = serde_json::from_str::(&body_text) + .ok() + .and_then(|v| { + v.get("id") + .and_then(|id| id.as_str().map(std::string::ToString::to_string)) + }); + + let urn = header_urn.or(json_urn).ok_or_else(|| { + cja::color_eyre::eyre::eyre!( + "LinkedIn /rest/posts response missing both x-restli-id header and id field" + ) + })?; + + Ok(CreatePostResponse { urn }) + } +} + +/// Refresh the stored LinkedIn access token. Updates the DB row with both +/// the new access token and the new refresh token (LinkedIn rotates both). +/// Returns the new plaintext access token. +#[tracing::instrument(name = "refresh_linkedin_token", skip_all, fields(linkedin_user_id = %row.linkedin_user_id))] +pub(crate) async fn refresh_linkedin_token( + pool: &PgPool, + encrypt_config: &encrypt::Config, + config: &LinkedInConfig, + row: &LinkedInUserRow, +) -> cja::Result { + let refresh_token = encrypt_config.decrypt(&row.encrypted_refresh_token)?; + + let client = reqwest::Client::new(); + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", &refresh_token), + ("client_id", &config.client_id), + ("client_secret", &config.client_secret), + ]; + + let resp = client + .post("https://www.linkedin.com/oauth/v2/accessToken") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "LinkedIn refresh token request failed ({status}): {body}" + )); + } + + let token_data: LinkedInRefreshTokenResponse = resp.json().await?; + + let encrypted_access = encrypt_config.encrypt(&token_data.access_token)?; + let encrypted_refresh = encrypt_config.encrypt(&token_data.refresh_token)?; + + let access_expires_at = chrono::Utc::now() + chrono::Duration::seconds(token_data.expires_in); + let refresh_expires_at = + chrono::Utc::now() + chrono::Duration::seconds(token_data.refresh_token_expires_in); + + sqlx::query!( + r#" + UPDATE LinkedInUsers + SET + encrypted_access_token = $1, + access_token_expires_at = $2, + encrypted_refresh_token = $3, + refresh_token_expires_at = $4, + updated_at = NOW() + WHERE linkedin_user_id = $5 + "#, + encrypted_access, + access_expires_at, + encrypted_refresh, + refresh_expires_at, + row.linkedin_user_id, + ) + .execute(pool) + .await?; + + Ok(token_data.access_token) +} + +/// Extract the first paragraph of the markdown body as plain text. +/// `body_markdown` MUST have the YAML frontmatter already stripped upstream +/// — ParseOptions::default() is correct for body-only input. +pub fn extract_first_paragraph(body_markdown: &str) -> String { + use markdown::mdast::Node; + let options = markdown::ParseOptions::default(); + let Ok(root) = markdown::to_mdast(body_markdown, &options) else { + return String::new(); + }; + let Node::Root(root) = root else { + return String::new(); + }; + + for child in &root.children { + if let Node::Paragraph(p) = child { + let mut out = String::new(); + render_inline(&Node::Paragraph(p.clone()), &mut out); + return out; + } + } + String::new() +} + +fn render_inline(node: &markdown::mdast::Node, out: &mut String) { + use markdown::mdast::Node; + match node { + Node::Paragraph(p) => { + for c in &p.children { + render_inline(c, out); + } + } + Node::Text(t) => out.push_str(&t.value), + Node::Strong(s) => { + for c in &s.children { + render_inline(c, out); + } + } + Node::Emphasis(e) => { + for c in &e.children { + render_inline(c, out); + } + } + Node::Delete(d) => { + for c in &d.children { + render_inline(c, out); + } + } + Node::Link(l) => { + for c in &l.children { + render_inline(c, out); + } + } + Node::InlineCode(c) => out.push_str(&c.value), + Node::Break(_) => out.push('\n'), + // Images, HTML, footnote/link references, MDX, math: skip. + _ => {} + } +} + +/// Compose the LinkedIn post body: custom-or-first-paragraph + footer. +/// If `custom_or_first_paragraph` is empty after trim, substitutes `title` +/// so LinkedIn doesn't reject the post with a 422 for empty commentary. +/// The footer is appended unconditionally — this may duplicate a footer if +/// the author wrote one themselves; that's an acceptable trade. +pub fn compose_linkedin_body( + custom_or_first_paragraph: &str, + title: &str, + canonical_url: &str, +) -> String { + let body = if custom_or_first_paragraph.trim().is_empty() { + title + } else { + custom_or_first_paragraph + }; + format!("{body}\n\nNew post on coreyja.com\n{canonical_url}") +} + +/// Convert any `urn:li::` URN into a LinkedIn web URL pointing at +/// the post. Works for `share`, `ugcPost`, and `activity` URNs alike. +pub fn linkedin_urn_to_web_url(urn: &str) -> cja::Result { + if !urn.starts_with("urn:li:") { + return Err(cja::color_eyre::eyre::eyre!( + "Invalid LinkedIn URN (missing urn:li: prefix): {urn}" + )); + } + // Must have at least 3 colon-separated segments after "urn:li:". + let segments: Vec<&str> = urn.split(':').collect(); + if segments.len() < 4 { + return Err(cja::color_eyre::eyre::eyre!( + "Invalid LinkedIn URN (too few segments): {urn}" + )); + } + Ok(format!("https://www.linkedin.com/feed/update/{urn}/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== extract_first_paragraph ==================== + + #[test] + fn extract_first_paragraph_basic() { + let md = "# Heading\n\nFirst para.\n\nSecond para."; + assert_eq!(extract_first_paragraph(md), "First para."); + } + + #[test] + fn extract_first_paragraph_strips_inline_markers() { + let md = "**Hello** _world_ [link](https://x)"; + assert_eq!(extract_first_paragraph(md), "Hello world link"); + } + + #[test] + fn extract_first_paragraph_keeps_inline_code() { + let md = "text with `code` survives"; + assert_eq!(extract_first_paragraph(md), "text with code survives"); + } + + #[test] + fn extract_first_paragraph_empty_body() { + assert_eq!(extract_first_paragraph(""), ""); + } + + #[test] + fn extract_first_paragraph_only_heading() { + assert_eq!(extract_first_paragraph("# Just a heading"), ""); + } + + #[test] + fn extract_first_paragraph_image_dropped() { + let md = "Look ![alt](https://x/img.png) here"; + let out = extract_first_paragraph(md); + assert!(!out.contains("![")); + assert!(!out.contains("https://x/img.png")); + assert!(out.contains("Look")); + assert!(out.contains("here")); + } + + // ==================== compose_linkedin_body ==================== + + #[test] + fn compose_linkedin_body_appends_footer() { + let out = compose_linkedin_body("hi", "Title", "https://coreyja.com/x"); + assert_eq!(out, "hi\n\nNew post on coreyja.com\nhttps://coreyja.com/x"); + } + + #[test] + fn compose_linkedin_body_falls_back_to_title_when_empty() { + let out = compose_linkedin_body(" ", "My Title", "https://coreyja.com/x"); + assert_eq!( + out, + "My Title\n\nNew post on coreyja.com\nhttps://coreyja.com/x" + ); + } + + // ==================== linkedin_urn_to_web_url ==================== + + #[test] + fn linkedin_urn_to_web_url_share() { + assert_eq!( + linkedin_urn_to_web_url("urn:li:share:1234").unwrap(), + "https://www.linkedin.com/feed/update/urn:li:share:1234/" + ); + } + + #[test] + fn linkedin_urn_to_web_url_ugc_post() { + assert_eq!( + linkedin_urn_to_web_url("urn:li:ugcPost:abc").unwrap(), + "https://www.linkedin.com/feed/update/urn:li:ugcPost:abc/" + ); + } + + #[test] + fn linkedin_urn_to_web_url_activity() { + assert_eq!( + linkedin_urn_to_web_url("urn:li:activity:7").unwrap(), + "https://www.linkedin.com/feed/update/urn:li:activity:7/" + ); + } + + #[test] + fn linkedin_urn_to_web_url_invalid_returns_err() { + assert!(linkedin_urn_to_web_url("not-a-urn").is_err()); + assert!(linkedin_urn_to_web_url("urn:other:x:y").is_err()); + } + + // ==================== from_env_optional ==================== + // + // Consolidated into a single test that toggles env vars sequentially. + // `cargo test` runs tests in parallel by default, so two separate + // env-mutating tests could race and observe each other's writes. Keeping + // all three assertions in one function gives a stable execution order. + // Adding `serial_test` as a dev-dep would be the alternative; one test + // function is cheaper. + + #[test] + fn from_env_optional_handles_all_combinations() { + let prev_id = std::env::var("LINKEDIN_CLIENT_ID").ok(); + let prev_secret = std::env::var("LINKEDIN_CLIENT_SECRET").ok(); + + // 1. Both unset → Ok(None) + std::env::remove_var("LINKEDIN_CLIENT_ID"); + std::env::remove_var("LINKEDIN_CLIENT_SECRET"); + let result = LinkedInConfig::from_env_optional(); + let none_result = result.expect("both unset should be Ok"); + assert!(none_result.is_none(), "both unset should be Ok(None)"); + + // 2. Both set → Ok(Some(_)) + std::env::set_var("LINKEDIN_CLIENT_ID", "id-test"); + std::env::set_var("LINKEDIN_CLIENT_SECRET", "secret-test"); + let some_result = LinkedInConfig::from_env_optional() + .expect("both set should be Ok") + .expect("both set should be Some"); + assert_eq!(some_result.client_id, "id-test"); + assert_eq!(some_result.client_secret, "secret-test"); + + // 3. Only ID set → Err (partial-config trap) + std::env::set_var("LINKEDIN_CLIENT_ID", "only-id"); + std::env::remove_var("LINKEDIN_CLIENT_SECRET"); + assert!( + LinkedInConfig::from_env_optional().is_err(), + "only ID set should be Err" + ); + + // 4. Only secret set → Err (partial-config trap, other direction) + std::env::remove_var("LINKEDIN_CLIENT_ID"); + std::env::set_var("LINKEDIN_CLIENT_SECRET", "only-secret"); + assert!( + LinkedInConfig::from_env_optional().is_err(), + "only secret set should be Err" + ); + + // Restore prior values. + match prev_id { + Some(v) => std::env::set_var("LINKEDIN_CLIENT_ID", v), + None => std::env::remove_var("LINKEDIN_CLIENT_ID"), + } + match prev_secret { + Some(v) => std::env::set_var("LINKEDIN_CLIENT_SECRET", v), + None => std::env::remove_var("LINKEDIN_CLIENT_SECRET"), + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 1d43472c..560deca9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -43,6 +43,8 @@ pub(crate) mod discord_interactive; pub(crate) mod bluesky; pub(crate) mod bsky; +pub(crate) mod linkedin; + pub(crate) mod al; pub(crate) mod memory; diff --git a/server/src/state.rs b/server/src/state.rs index 01ad8872..3baaa9bd 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -13,7 +13,7 @@ use url::Url; use crate::{ anthropic::AnthropicConfig, discord::DiscordClient, encrypt, github::GithubConfig, google::GoogleConfig, http_server::pages::blog::md::SyntaxHighlightingContext, - linear::LinearConfig, twitch::TwitchConfig, + linear::LinearConfig, linkedin::LinkedInConfig, twitch::TwitchConfig, }; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -122,6 +122,7 @@ pub(crate) struct AppState { pub encrypt_config: encrypt::Config, pub posthog_key: Option, pub discord: DiscordClient, + pub linkedin: Option, } impl AppState { @@ -164,6 +165,7 @@ impl AppState { encrypt_config: encrypt::Config::from_env()?, posthog_key: std::env::var("POSTHOG_KEY").ok(), discord, + linkedin: LinkedInConfig::from_env_optional()?, }; Ok(app_state)