From c3cf597950ce992cd8942a4d7c023033352246fa Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 24 Oct 2026 20:20:21 +0100 Subject: [PATCH 1/9] Persist the theme selector choice across page loads Save an explicit light/dark choice to localStorage and clear it when switching back to system. An inline head script applies the saved colour scheme before first paint to avoid a flash of the system theme. Co-Authored-By: Claude Opus 5.5 --- public/assets/js/app.js | 2 +- public/assets/js/app.js.br | Bin 1304 -> 1393 bytes public/assets/js/app.js.map | 6 +++--- public/assets/js/app.js.zst | Bin 1502 -> 1611 bytes resources/js/theme-selector.js | 33 ++++++++++++++++++++++++++++--- resources/views/master.blade.php | 9 +++++++++ 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 297c3aa1..f1b9a1a9 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1,2 +1,2 @@ -(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})(); +(()=>{var l=class{constructor(){}async register(){let e=await this.getCreateOptions(),t={challenge:this.base64URLStringToBuffer(e.challenge),rp:{id:e.rp.id,name:e.rp.name},user:{id:new TextEncoder().encode(window.atob(e.user.id)),name:e.user.name,displayName:e.user.displayName},pubKeyCredParams:e.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:e.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:t});if(!r)throw new Error("Error generating a passkey");let n={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let e=await this.getLoginData(),t=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(e.challenge),userVerification:e.userVerification,timeout:6e4}});if(!t)throw new Error("Authentication failed");let r={id:t.id?t.id:"",type:t.type?t.type:"",rawId:t.rawId?this.bufferToBase64URLString(t.rawId):"",response:{authenticatorData:t.response.authenticatorData?this.bufferToBase64URLString(t.response.authenticatorData):"",clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):"",signature:t.response.signature?this.bufferToBase64URLString(t.response.signature):"",userHandle:t.response.userHandle?this.bufferToBase64URLString(t.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),s=window.atob(n),i=new ArrayBuffer(s.length),o=new Uint8Array(i);for(let c=0;c{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(e){let t=e==="light"||e==="dark"?e:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",n=document.documentElement,s=n.style.getPropertyValue("color-scheme");s===""&&(s="light dark");let i=!1;t!==s&&(t==="light dark"?i=s!==r:s==="light dark"?i=t!==r:i=!0);let o=()=>{this.currentTheme=e,n.style.setProperty("color-scheme",t),a.storeTheme(e),this.updateButtons(),this.status.textContent=e==="system"?"Theme set to system default":""};n.style.viewTransitionName="changing-theme",i&&document.startViewTransition?document.startViewTransition(o):o()}};var d=new l;document.querySelectorAll(".add-passkey").forEach(a=>{a.addEventListener("click",()=>{d.register()})});document.querySelectorAll(".login-passkey").forEach(a=>{a.addEventListener("click",()=>{d.login()})});var u=new h;u.setupEventListeners();})(); //# sourceMappingURL=app.js.map diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br index 7567f865c50b428a6a88fb6715b1ba0166f8018c..b24963915b12c32a275c4b5590b660c0790746b1 100644 GIT binary patch literal 1393 zcmcckqMt!w_R3%HpD$JDwrCEq5({;aEjAR&(RTha@kGdr|GU3sPG6gM^V^k`_V4#i zw>%r*pEf=C(9QTKDaNPYth#8(5&W>mw{5fLT+gP6br;tEUuVcxSh3 zK5WaG=AJs#QT29VhN_H$NaYzl7k3*Kqw?8~LG$ML?s5C{_QP7~c)NY)=G)GHZ1W?2 zTFQ~kf7U@!KQmwK-La*}*?(a&(_Ej00nV2nXe<{tHuFE$5OQ+CovJBqFni|{f=Rh|C}|^)@Q}1$ENH>d^eUXmbA3eoK*5#@uQG$lfcE7_bwW}PvKgw z&-0-8)-o-j!pY}9Cca8@OYUmBI_b5^@&#IxPD;*NBO*I_w=-{flW+H9orzbLOE2tv zJg+_av0&O1`BY8M3A3h5mZ`WOvn5(;g`)Oq<$dPYlTsShJoRiDR;pdRa_`=igCWkL z&(mC-=A1e#@pgNjLWk|-zbqRj+(-?c#jwdYxogk-2fJ&(ynGzIdr=E_f7Jn(bM?oQ zjc%XH`q4Tibo%8xec^?N=jX<3SFqeN`=0Q$rWcPK`=rl2RN21v`cf0SH4pD6xwpM| zF<02X{7?5rp*bdjEL`pRd$i(C06e)mnD$Lrtmr(f4~t`e!PT(C_0re3y&WH9T^*Yl=5_{wPU zYi`WZ%B3^Xa%Md9SFPUi-Z*k^iGQ+z=C(8MZx%MP7Dey9Y=4&hl*KO1<7GPHtDTqy z9~b>8vDiFqL)@A78)s%PdK!4kR!@~b|LsSwyHu~Oja}xXHue?e7FXV1ZS8*Yko}{Y ziT2{4f7j>VJZ`bW)3b4sWNgf{>49^inwM5DUj6&bl9zi#JAXCzY@H$%^}dtSBHm#V zFSoJthSR6LnVO1siSieVYX;kkEbNwC$Npo&DKW9mYn^NDcN}{(Prb@_a!S9Pq9eaf znAZ<=zn8Bhb(Tq95_n^?^cl;6e-(Yq_MB=zZrnH_x$sC(xO?dIyxJj+8 z*Jsl&M){$ua5BL6>PaS>70i`kgWP7Pny9#dkUmnKiC+Rp&`4 zbh=+&v+G<+SX)1DbhKt8pM>y!sX1k<-?cd-{V z9xmay%cxTOM54v=;h&j3X2Po*470v}xc{p3n8JJBMnAbt?++<1zdK>m!NeB7mkSnF z|6Q1|R7OFha!skC)gwj4;&KUrTpd&%gWQ)5p?(KJ|W|AJ{A2T(S92`I0@~ z4zK$D>eH>YRa~;!7Yq%9RwS+zcv_NltW(`kLHXu_$y+^E{*Jj|V*D!O*qv4GCA_nC z{1mbLe1Deh+P`-vUOT=yyy%~Xx4qSBvAw${Jlkw?@$|cVp;A%4?*Cug4{g-z7mmF! z<@X1xw(#^90a`Wsva9#2n!BBn+3vse!Jaw+^~tw z?`zMsZg<>zLeB4nOzGXmIm~7+XK2XG74h`@eeA1{Z&SjH%WD^`4^m%x=b2D)i{HmX1Ow;BtC8Lxl6 z(bex;*drH)N9(b71QQ#5PKh&{F?QkNWk_t@t(EXsb=PDuGkkx zn}2w^JS5_d)ffMye4fRb%QT+yPV6>6%&J-NW!p*lJ9T+7hm>WKBKO*-eOmt05i`{mZ&$*0-B>9kXvQpHetynM`y!KjY(|KSdU+ z)e;v^n7_?+v*?5)Mk_bE|NQx;Z{D2^Gd8sUUapy$(J=Gvn>y|DF$(kl_3b@5O?9Ql z-^c2w9yQep9gC9J^0jfh+VUiAwUkzEeYalEts6_D7~d>@#`Ju;0Z;Ty#Rrb3t==lC ziAnMopX*!XZ8=$Pu6WOpWg>P@57thf{;FmDzLM3&ZJQDnu4;2sGP{58+B<^;wbM_g zp4y(Cy?9;u3Q@-GYlEWPoZd2P{q(I9cq`aLN;uvq= zR@?N*?XNhK|2i|H#J*{KT%Q-N=9TH~KVi43$Z*bwGFfj`Ki=9m=~HT=*S@gXCE0l( zqs>f;yHfPcnq6!xiXSA>CC}!S#XL9>;M$Q^=x(xif^f<*&ute3|36cT%3S4>V&8Jb zf_K(|puIm=DyCJ~9NP5N@l@6d>qFnlm=?F~{h1{wNiA#7*m8bAe~hZ0VQ>)=c4bHL3SytQ~~B f-(HBiF1=V?K>6gmU!Ojj*6%w1R=ix~S~vp$sqctP diff --git a/public/assets/js/app.js.map b/public/assets/js/app.js.map index a2fb0730..e5228e3e 100644 --- a/public/assets/js/app.js.map +++ b/public/assets/js/app.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../../resources/js/auth.js", "../../../resources/js/theme-selector.js", "../../../resources/js/app.js"], - "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = 'system';\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n\n this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], - "mappings": "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", - "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] + "sourcesContent": ["class Auth {\n constructor() {}\n\n async register() {\n const createOptions = await this.getCreateOptions();\n\n const publicKeyCredentialCreationOptions = {\n challenge: this.base64URLStringToBuffer(createOptions.challenge),\n rp: {\n id: createOptions.rp.id,\n name: createOptions.rp.name,\n },\n user: {\n id: new TextEncoder().encode(window.atob(createOptions.user.id)),\n name: createOptions.user.name,\n displayName: createOptions.user.displayName,\n },\n pubKeyCredParams: createOptions.pubKeyCredParams,\n excludeCredentials: [],\n authenticatorSelection: createOptions.authenticatorSelection,\n timeout: 60000,\n };\n\n const credential = await navigator.credentials.create({\n publicKey: publicKeyCredentialCreationOptions\n });\n if (!credential) {\n throw new Error('Error generating a passkey');\n }\n\n const authenticatorAttestationResponse = {\n id: credential.id ? credential.id : null,\n type: credential.type ? credential.type : null,\n rawId: credential.rawId ? this.bufferToBase64URLString(credential.rawId) : null,\n response: {\n attestationObject: credential.response.attestationObject ? this.bufferToBase64URLString(credential.response.attestationObject) : null,\n clientDataJSON: credential.response.clientDataJSON ? this.bufferToBase64URLString(credential.response.clientDataJSON) : null,\n }\n };\n\n const registerCredential = await window.fetch('/admin/passkeys/register', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n cache: 'no-cache',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!registerCredential.ok) {\n throw new Error('Error saving the passkey');\n }\n\n window.location.reload();\n }\n\n async getCreateOptions() {\n const response = await fetch('/admin/passkeys/register', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n async login() {\n const loginData = await this.getLoginData();\n\n const publicKeyCredential = await navigator.credentials.get({\n publicKey: {\n challenge: this.base64URLStringToBuffer(loginData.challenge),\n userVerification: loginData.userVerification,\n timeout: 60000,\n }\n });\n\n if (!publicKeyCredential) {\n throw new Error('Authentication failed');\n }\n\n const authenticatorAttestationResponse = {\n id: publicKeyCredential.id ? publicKeyCredential.id : '',\n type: publicKeyCredential.type ? publicKeyCredential.type : '',\n rawId: publicKeyCredential.rawId ? this.bufferToBase64URLString(publicKeyCredential.rawId) : '',\n response: {\n authenticatorData: publicKeyCredential.response.authenticatorData ? this.bufferToBase64URLString(publicKeyCredential.response.authenticatorData) : '',\n clientDataJSON: publicKeyCredential.response.clientDataJSON ? this.bufferToBase64URLString(publicKeyCredential.response.clientDataJSON) : '',\n signature: publicKeyCredential.response.signature ? this.bufferToBase64URLString(publicKeyCredential.response.signature) : '',\n userHandle: publicKeyCredential.response.userHandle ? this.bufferToBase64URLString(publicKeyCredential.response.userHandle) : '',\n },\n };\n\n const loginAttempt = await window.fetch('/login/passkey', {\n method: 'POST',\n body: JSON.stringify(authenticatorAttestationResponse),\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n });\n\n if (!loginAttempt.ok) {\n throw new Error('Login failed');\n }\n\n window.location.assign('/admin');\n }\n\n async getLoginData() {\n const response = await fetch('/login/passkey', {\n method: 'GET',\n });\n\n return await response.json();\n }\n\n /**\n * Convert a base64 URL string to a buffer.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/base64URLStringToBuffer.ts#L8\n *\n * @param {string} base64URLString\n * @returns {ArrayBuffer}\n */\n base64URLStringToBuffer(base64URLString) {\n // Convert from Base64URL to Base64\n const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/');\n /**\n * Pad with '=' until it's a multiple of four\n * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding\n * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding\n * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding\n * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding\n */\n const padLength = (4 - (base64.length % 4)) % 4;\n const padded = base64.padEnd(base64.length + padLength, '=');\n // Convert to a binary string\n const binary = window.atob(padded);\n // Convert binary string to buffer\n const buffer = new ArrayBuffer(binary.length);\n const bytes = new Uint8Array(buffer);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return buffer;\n }\n\n /**\n * Convert a buffer to a base64 URL string.\n *\n * Sourced from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/browser/src/helpers/bufferToBase64URLString.ts#L7\n *\n * @param {ArrayBuffer} buffer\n * @returns {string}\n */\n bufferToBase64URLString(buffer) {\n const bytes = new Uint8Array(buffer);\n let str = '';\n for (const charCode of bytes) {\n str += String.fromCharCode(charCode);\n }\n const base64String = btoa(str);\n return base64String.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n }\n}\n\nexport { Auth };\n", "class ThemeSelector {\n constructor() {\n this.widget = document.querySelector('#theme-selector');\n this.btnLight = this.widget.querySelector('#btn-light');\n this.btnDark = this.widget.querySelector('#btn-dark');\n this.status = this.widget.querySelector('#theme-status');\n this.currentTheme = ThemeSelector.storedTheme();\n this.updateButtons();\n }\n\n static storedTheme() {\n try {\n const theme = localStorage.getItem('theme');\n return (theme === 'light' || theme === 'dark') ? theme : 'system';\n } catch {\n return 'system';\n }\n }\n\n static storeTheme(theme) {\n try {\n if (theme === 'system') {\n localStorage.removeItem('theme');\n } else {\n localStorage.setItem('theme', theme);\n }\n } catch {\n /* storage unavailable, the choice just won't persist */\n }\n }\n\n updateButtons() {\n this.btnLight.setAttribute('aria-pressed', String(this.currentTheme === 'light'));\n this.btnDark.setAttribute('aria-pressed', String(this.currentTheme === 'dark'));\n }\n\n setupEventListeners() {\n this.btnLight.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');\n });\n\n this.btnDark.addEventListener('click', () => {\n this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');\n });\n }\n\n applyTheme(theme) {\n const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';\n\n const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';\n const html = document.documentElement;\n let currentColorScheme = html.style.getPropertyValue('color-scheme');\n if (currentColorScheme === '') {\n currentColorScheme = 'light dark';\n }\n\n /* do we need to transition */\n let doTransition = false;\n\n if (selectedTheme !== currentColorScheme) {\n if (selectedTheme === 'light dark') {\n doTransition = currentColorScheme !== systemTheme;\n } else if (currentColorScheme === 'light dark') {\n doTransition = selectedTheme !== systemTheme;\n } else {\n doTransition = true;\n }\n }\n\n const applyChange = () => {\n this.currentTheme = theme;\n html.style.setProperty('color-scheme', selectedTheme);\n ThemeSelector.storeTheme(theme);\n\n this.updateButtons();\n this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';\n };\n\n html.style.viewTransitionName = 'changing-theme';\n if (doTransition && document.startViewTransition) {\n document.startViewTransition(applyChange);\n } else {\n applyChange();\n }\n }\n}\n\nexport { ThemeSelector };\n", "import { Auth } from './auth.js';\nimport { ThemeSelector } from './theme-selector.js';\n\nlet auth = new Auth();\ndocument.querySelectorAll('.add-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.register();\n });\n});\n\ndocument.querySelectorAll('.login-passkey').forEach((el) => {\n el.addEventListener('click', () => {\n auth.login();\n });\n});\n\nlet themeSelector = new ThemeSelector();\nthemeSelector.setupEventListeners();\n"], + "mappings": "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", + "names": ["Auth", "createOptions", "publicKeyCredentialCreationOptions", "credential", "authenticatorAttestationResponse", "loginData", "publicKeyCredential", "base64URLString", "base64", "padLength", "padded", "binary", "buffer", "bytes", "i", "str", "charCode", "ThemeSelector", "_ThemeSelector", "theme", "selectedTheme", "systemTheme", "html", "currentColorScheme", "doTransition", "applyChange", "auth", "Auth", "el", "themeSelector", "ThemeSelector"] } diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index f9a711706ea4fb17f9545e0c604f4eb06253078a..ae85c89d9a7cd53cd0f2bca3df62aa9e49956a45 100644 GIT binary patch literal 1611 zcmdPcs{c3TpTJu~hF9(%c(gA}vf#gPh=2B}Qt=mox7&BWaeiu;Um7aOSL>m9)TU+T z-PCn!4~oWFc^zfh&v1<42*X{$V?oxxt_k!g$*=vzeyTF@54WsGo=$GK?}o2#z^`H6JWFdQ5+|7HO6L&W~;+X8YYeAWp-s0@DiDoPJ_bqkP z((RcWq1d~JeVT#Tva@zOyR^Myr}SOnvk#1uTNztC!!%f9a+6l$iZ|ysEC@(E#PHis zu1(|oPQg8U%6l!>ep2Q6_jFxFo6Pi!#`@Pc_0N2M^1aZbG=-%#tG$zB<^BHpY46$b zz9rviuk>`5^@d_XtBO69cqMLKE{>P#j#l3utnR*lMuKIdCbg&-t)UUTn1eM<18GTUxduS+&h zmv2pqmCNrtJu%vM>Fb9adL_L^T}pQ=S461ic^Vn0tx>=BVrNm|>4$t;+J&;+t{-L| zTJ?%anfcH^y^2#sPR|vKXC%C_{{Q}I;_}dStbe4JcDlaX<^O5QZuJ+z zS6;*#%>FK@qWhxyuxOv%fvWQXQ>LXZI)3{>M6XL*T6F!RHH(5~KeFk}J0GC+IJ;0) zo_Evo2NBx0=CgGyG~?v_;lLozB9{`Y*{^o9arq{v(z7eyF01(TGkTqB#FB|SCVlST z#mSiKF0Y1qjLxy#WqnKf^A^u)V6m!_q@U%E=ly1rs@*bYUR zgYgl%%b6MjRwQn8PH{_dahar|SrW+6_%Py%=Sk^F7fig1WtatxS!Qy&xTG*CdOA9C zt(wU!DpR*%n(YgL1t$|XF^DVYaz4ow+bdzR_|@^WOL;Df<#SHPKGr?WzBak_qp-3T zTi}a)hj|fick>Bkwtd!Rk5eg^4cA#ATJMo|hL!z8+VL=%3qOv0J+6>1a$Pg4mw6d$ z*!_LSbZ1C8b{kCExJcUU)3R!oX$yWT+Lsx~KRs-9yYFJc>pZRRg4YD)c#pna*U2(<;6AIrIwoaPx_xGDIp!POYFhRvqh{6Rvf(E zm$OJop=Gh`A>|Y5sY)yKN?PF$5w*f+nkbhx>Zdt-m~<84a!Pv%`Za6RZtlxT9+ ztJ~`r?KPQMzwpSXqkrOU7G94v+jb%Tdiviglk^hqijMw$?T(HG=E2#%QLQI0MJmhp z_pXRu8{82Yb7jsA1_kz4r2)39+%Iu9PCMoIsM5^!=E_;IZ)B^jt)#ECJ}hq+WYP=y z$=5ACBWY3d$>em#X}SC<*SDyfcjy)uFidl9VwWz{%y?eibxwcx1%u|?C5wJsU37g@ zqONua+e@eJNx5t<_CDOfo3G0!I!pciuOpp*^iQ<)i~TpL=W+W}tu#+ZFG0#zY|na! zgHPgwzD${PrSV+cjd@$IAE>@^_~Rai4Llwm|C#?c@;l9Yqjw@lxop*~zjwIm-+xh1 z72r$n@UXJp*_s?arDfjY1_i~n8z1G#Z`#^+Zko@dm5nSCD>RQ(zu}nQAhNFF^rzAv lvROMe?l@tZT*}wHv)$#zOb*j{wdG7RPq7}ma`D^OL;%1`0~G)O literal 1502 zcmdPcs{c1-K7Xqo1DCS~kJ<%^=Zp`WZ+X=3vP~~VX7x|$T6uI`gtDss zo(EoX27Fr>b}?*cSh()d5{2vMCq6nOvQDp6C~D!aGiB-fL}qVmzx}+T_LxwoN2%ud z^>>XH2QFQDJ29Q_Cf~=G-M(#^H8YaRyit>BYrrJjEF88bF@9FDjY2CMH<*eY@LHm?Awtcy?w#;tZ z#q)0k?#sM7m%XX|q5J(U|NZ=`MP5HI;`lP>cK^)(Yt5S8rAjIG&Ixwl{a`7sFH)sz zc71;Cvj5h5m?nk&KJqW+XD@Hphw|kQ9?xAE5Wjn}tM=+;6L;RY;3Fg}ClKs0Icn?m zBSPv&4e~-S`^5iV-I}rBRh9QU@2tsd*S^v&nsTpHK09vJFOS_5XB|s!UL1NU?!N4V zD>~PM(%m;L-Kkv{ttWEn#)l0Lo_DOb&0J!)?POZ>|8MrqtM*+@+CKkqNW~4q-rBfD zCaXo+?w!t_Z?|*$#tvn_x##a~wG1rIyK6l0_^+?$ndg}5>(4tn`=s!54y%LvErb{U zo4f1sb#9X%XD41(*57dCl3KH1c}{(>tjqCTu8z$0vXN%1*4DoGw5Ym$n%E(2l}@E^ zk`hO_o%hHbcyT9c`tiJnum5bgv|7Hbyn#nMXOgrtg(zP|1e|K!1UutUCw>LYBp1pmyYIpSOvYREFL@l?N?sYVH^(5k! z$;{3bRo~t&c&-wC%P;cQ_2nOIH(udpkSJe~?c~VwxTkO4O=->u9R~f9g`2w1$d&pA zFwc3{HK#B;Ft~V%Rm_X0IbYWtZrJnx$jX|`GtoCq=grfc{>>zLb*jisY11>SzXbbd zZF--3Ty5St-Po54ZC^xHw)U(PVEe+V$Y8XluVKN}XI6`OK2_$Qv2wrcfBDQ*gYEIv zp?fArXFV?2ySD6%sk_60;Id<)^R4w<|7&Y6ys+AU*XjGjTb)yUD#Lr$&z*E|(|hTx zx&MCeiSCtQ>@ zH9QhiBbY@e?7VpDo&aBg!olvD)~2qk7Fyl*2Gdv1oG!rjKAUr|+trm{^-WIXGhGc} z4LyCGsi^DizDH77t@mChwr#(h{bS1;`7)?SZo$ZczW+<=WpZ7D?5yfLh3*L(q(=Bvf|Wp zH+IR-GcTG3Dpf4aS8}W1Zf*DOXsF8R%Kv^itFI@@=rPlkh6pa^w(xBxCAZG8>OA!O z&6>ioHm_A_&Roxlw22mZ&`B@h6F|$HTw{4Z}|Fe{Djo^)CnV)m#B!}Fw;qc+- zpL&&3SgP%MwEM=V*1Id+>U~4A6Q=I?+JE(cpjP>28R^51l#WG-U7d9^+k2j{)VApt zxkLVG%;5Wc>dfLr5B)E?epX}rv_dR|eWq2LK&X5Em3*BEl0T$WzWmV*PYPKO_gKqr ziRaJmDL~*ANz0nk2v)WVc$*D_pr*ncl$1*(y>i8zw~mK+J@6vPY&Pkie2TW zcuX(w(-B39M49XEwRW|ZLJfKL6OZUg&)sDGi|54D)ZbPoOTKSoU?%*y-XK6gb< z<~!N#WR19CHR0xhzjqauo;k>Rqxm!2=JK6eH`^b4G5u@CnFo)r9F1$NSSlGdt+_%X zwr1^=$%fC&S+{3i?+i9bFWW|w_0A!@4t g54SN7o0rnsRSFM+XZ{pE7^bc?ontjq#v{{-0GzeyWdHyG diff --git a/resources/js/theme-selector.js b/resources/js/theme-selector.js index 374fdc8c..582a7b4c 100644 --- a/resources/js/theme-selector.js +++ b/resources/js/theme-selector.js @@ -4,7 +4,34 @@ class ThemeSelector { this.btnLight = this.widget.querySelector('#btn-light'); this.btnDark = this.widget.querySelector('#btn-dark'); this.status = this.widget.querySelector('#theme-status'); - this.currentTheme = 'system'; + this.currentTheme = ThemeSelector.storedTheme(); + this.updateButtons(); + } + + static storedTheme() { + try { + const theme = localStorage.getItem('theme'); + return (theme === 'light' || theme === 'dark') ? theme : 'system'; + } catch { + return 'system'; + } + } + + static storeTheme(theme) { + try { + if (theme === 'system') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', theme); + } + } catch { + /* storage unavailable, the choice just won't persist */ + } + } + + updateButtons() { + this.btnLight.setAttribute('aria-pressed', String(this.currentTheme === 'light')); + this.btnDark.setAttribute('aria-pressed', String(this.currentTheme === 'dark')); } setupEventListeners() { @@ -43,9 +70,9 @@ class ThemeSelector { const applyChange = () => { this.currentTheme = theme; html.style.setProperty('color-scheme', selectedTheme); + ThemeSelector.storeTheme(theme); - this.btnLight.setAttribute('aria-pressed', String(theme === 'light')); - this.btnDark.setAttribute('aria-pressed', String(theme === 'dark')); + this.updateButtons(); this.status.textContent = theme === 'system' ? 'Theme set to system default' : ''; }; diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 4f3303ab..cbac614e 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -25,6 +25,15 @@ @endif @stack('head') +
-- 2.56.0 From 7ce256baa5dc75c24cd2955caa4b23825b2e5f88 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 29 Oct 2026 19:01:28 +0000 Subject: [PATCH 2/9] Return 404 for non-numeric like and bookmark IDs Constrain the likes/{like} and bookmarks/{bookmark} routes to numeric IDs. Previously a junk URL such as /likes/twcamp^share|twsrc^ios was passed to route model binding, and Postgres rejected the text-vs-integer comparison with a QueryException, returning a 500 instead of a 404. Co-Authored-By: Claude Opus 5.5 --- routes/web.php | 4 ++-- tests/Feature/BookmarksTest.php | 7 +++++++ tests/Feature/LikesTest.php | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index a9b46407..d7e1ee2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -211,14 +211,14 @@ Route::get('note/{id}', [NotesController::class, 'redirect']); // for legacy not // Likes Route::prefix('likes')->group(function () { Route::get('/', [LikesController::class, 'index']); - Route::get('/{like}', [LikesController::class, 'show']); + Route::get('/{like}', [LikesController::class, 'show'])->whereNumber('like'); }); // Bookmarks Route::prefix('bookmarks')->group(function () { Route::get('/', [BookmarksController::class, 'index']); Route::redirect('/tagged', '/bookmarks'); - Route::get('/{bookmark}', [BookmarksController::class, 'show']); + Route::get('/{bookmark}', [BookmarksController::class, 'show'])->whereNumber('bookmark'); Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']); }); diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 7b741033..83f36d32 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -32,6 +32,13 @@ class BookmarksTest extends TestCase $response->assertViewIs('bookmarks.show'); } + #[Test] + public function non_numeric_bookmark_id_gives_not_found_response(): void + { + $response = $this->get('/bookmarks/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } + #[Test] public function when_bookmark_is_added_using_http_syntax_check_job_to_take_screenshot_is_invoked(): void { diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php index 2bda91f3..b08f0d37 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -178,4 +178,11 @@ class LikesTest extends TestCase $response = $this->get('/likes/202'); $response->assertNotFound(); } + + #[Test] + public function non_numeric_like_id_gives_not_found_response(): void + { + $response = $this->get('/likes/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } } -- 2.56.0 From 5481956d7875482394d65aeefd9fb1ab720bce5b Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 1 Nov 2026 15:28:30 +0000 Subject: [PATCH 3/9] Use `uri` for the like and bookmark permalinks `uri` is the attribute every other postable model uses for its absolute permalink on this site. Add it to Like, which had none, and rename Bookmark's `local_uri` to match. `url` stays as the thing being liked or bookmarked. Refs #148 Co-Authored-By: Claude Opus 5.5 --- app/Models/Bookmark.php | 2 +- app/Models/Like.php | 7 +++++++ tests/Feature/BookmarksTest.php | 4 ++-- tests/Unit/LikesTest.php | 8 ++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php index 1c5ae6e4..5b37d84c 100644 --- a/app/Models/Bookmark.php +++ b/app/Models/Bookmark.php @@ -25,7 +25,7 @@ class Bookmark extends Model return $this->belongsToMany('App\Models\Tag'); } - protected function localUri(): Attribute + protected function uri(): Attribute { return Attribute::get( get: fn () => config('app.url').'/bookmarks/'.$this->id, diff --git a/app/Models/Like.php b/app/Models/Like.php index 44f25b91..8f2521ef 100644 --- a/app/Models/Like.php +++ b/app/Models/Like.php @@ -18,6 +18,13 @@ class Like extends Model use FilterHtml; use HasFactory; + protected function uri(): Attribute + { + return Attribute::get( + get: fn () => config('app.url').'/likes/'.$this->id, + ); + } + protected function url(): Attribute { return Attribute::set( diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 83f36d32..fff9a267 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -92,10 +92,10 @@ class BookmarksTest extends TestCase } #[Test] - public function bookmark_local_uri_attribute_returns_correct_url(): void + public function bookmark_uri_attribute_returns_correct_url(): void { $bookmark = Bookmark::factory()->create(); - $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->local_uri); + $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->uri); } #[Test] diff --git a/tests/Unit/LikesTest.php b/tests/Unit/LikesTest.php index 74412833..ef4c21f2 100644 --- a/tests/Unit/LikesTest.php +++ b/tests/Unit/LikesTest.php @@ -13,6 +13,14 @@ class LikesTest extends TestCase { use RefreshDatabase; + #[Test] + public function like_uri_attribute_returns_our_permalink(): void + { + $like = Like::factory()->create(); + + $this->assertEquals(config('app.url').'/likes/'.$like->id, $like->uri); + } + #[Test] public function we_can_set_the_author_url(): void { -- 2.56.0 From e8f2535cd15117d610ac3f60b77640d6c773557a Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 1 Nov 2026 15:29:00 +0000 Subject: [PATCH 4/9] Return our permalink in the Micropub Location header for likes Likes returned `url`, which is the liked post rather than our page. Use `uri` instead. Bookmarks already asked for `uri`, which didn't exist until the previous commit, so their Location header was empty. Neither case had a test, so assert the header value for both. Refs #148 Co-Authored-By: Claude Opus 5.5 --- .../Micropub/Handlers/EntryHandler.php | 2 +- tests/Feature/MicropubControllerTest.php | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index d79a0418..6b68b88c 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface $dataArray = $data->toArray(); $location = match (true) { - isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, + isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->uri, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri, diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3fd8b515..5ff59844 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -9,6 +9,8 @@ use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; use App\Models\Article; +use App\Models\Bookmark; +use App\Models\Like; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -883,6 +885,50 @@ class MicropubControllerTest extends TestCase $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); } + #[Test] + public function micropub_client_api_request_for_a_like_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'like-of' => ['https://example.org/liked-post'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $like = Like::where('url', 'https://example.org/liked-post')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/likes/'.$like->id); + } + + #[Test] + public function micropub_client_api_request_for_a_bookmark_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'bookmark-of' => ['https://example.org/bookmarked-page'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $bookmark = Bookmark::where('url', 'https://example.org/bookmarked-page')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/bookmarks/'.$bookmark->id); + } + #[Test] public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void { -- 2.56.0 From 73b9668088d0035602b00fdb8ef9f831ad4c0082 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 1 Nov 2026 15:29:00 +0000 Subject: [PATCH 5/9] Link to likes through the `uri` attribute in the like template Refs #148 Co-Authored-By: Claude Opus 5.5 --- resources/views/templates/like.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/templates/like.blade.php b/resources/views/templates/like.blade.php index 5169a31e..2a114761 100644 --- a/resources/views/templates/like.blade.php +++ b/resources/views/templates/like.blade.php @@ -20,7 +20,7 @@ @endisset -- 2.56.0 From cc2ff2b2a279a9cf0927f96d2f17844645f0d12e Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 1 Nov 2026 15:29:05 +0000 Subject: [PATCH 6/9] Drop the unused notes.shorturl column The short domain is gone and nothing reads or writes this column any more. Refs #148 Co-Authored-By: Claude Opus 5.5 --- ..._120000_drop_shorturl_from_notes_table.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php diff --git a/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php new file mode 100644 index 00000000..e4f73202 --- /dev/null +++ b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php @@ -0,0 +1,28 @@ +dropColumn('shorturl'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notes', function (Blueprint $table) { + $table->string('shorturl', 20)->nullable(); + }); + } +}; -- 2.56.0 From 6e96ca8304d46e8b1eb83222c786a8c1a7b74680 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 24 Oct 2026 22:26:41 +0100 Subject: [PATCH 7/9] Bring Micropub endpoint closer to the spec - Detect form-encoded action=... requests, so form-encoded updates work and unhandled actions are no longer created as notes - Default to h-entry when no type is given - Read form-encoded photos from `photo` rather than `photos` - Pass `category` through EntryData so bookmark tags are saved - Accept single values for form-encoded multi-value properties - Return 403 for insufficient_scope and 401 for invalid tokens - Return 400 invalid_request for unsupported post types, update operations and models instead of reporting them as 500s Co-Authored-By: Claude Opus 5.5 --- app/Http/Controllers/MicropubController.php | 18 +-- app/Http/Requests/MicropubRequest.php | 21 ++- app/Http/Responses/MicropubResponses.php | 4 +- app/Services/Micropub/Data/EntryData.php | 18 ++- .../Micropub/MicropubHandlerRegistry.php | 2 +- tests/Feature/BookmarksTest.php | 22 ++- tests/Feature/MicropubControllerTest.php | 132 +++++++++++++++--- tests/Feature/MicropubMediaTest.php | 12 +- 8 files changed, 179 insertions(+), 50 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 72242150..ded95242 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -64,31 +64,17 @@ class MicropubController extends Controller return response()->json([ 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', - ], 401); + ], 403); } catch (ModelNotFoundException) { return response()->json([ 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException $e) { - report($e); - - return response()->json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only supports the updating of notes', - ], 500); - } catch (\InvalidArgumentException $e) { + } catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); - } catch (MicropubHandlerException $e) { - report($e); - - return response()->json([ - 'error' => 'unsupported_operation', - 'error_description' => 'The request could not be processed by this server', - ], 500); } catch (\Throwable $e) { report($e); diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index ab4fde74..7cbabc31 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -15,25 +15,36 @@ class MicropubRequest extends FormRequest ]; } + /** + * Determine the request type: the action (e.g. "update") if one is given, + * otherwise the microformat type without its "h-" prefix. Per the spec, a + * request with no type is treated as an h-entry. + */ public function getType(): ?string { if ($this->isJson()) { $data = $this->json()->all(); - if (isset($data['action']) && $data['action'] === 'update') { - return 'update'; + if (isset($data['action']) && is_string($data['action'])) { + return $data['action']; } if (isset($data['type']) && is_array($data['type'])) { $type = current($data['type']); - if (str_starts_with($type, 'h-')) { + if (is_string($type) && str_starts_with($type, 'h-')) { return substr($type, 2); } + + return null; } - return null; + return 'entry'; } - return $this->input('h') ?: null; + if ($this->filled('action')) { + return $this->input('action'); + } + + return $this->input('h') ?: 'entry'; } } diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php index 4f7240c2..d26dbf90 100644 --- a/app/Http/Responses/MicropubResponses.php +++ b/app/Http/Responses/MicropubResponses.php @@ -17,7 +17,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'insufficient_scope', 'error_description' => 'The token’s scope does not have the necessary requirements.', - ], 401); + ], 403); } /** @@ -29,7 +29,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'invalid_token', 'error_description' => 'The provided token did not pass validation', - ], 400); + ], 401); } /** diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 192c50f7..044127ce 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -25,6 +25,7 @@ class EntryData extends MicropubData public readonly mixed $syndication, public readonly ?array $photos, public readonly ?string $postStatus, + public readonly ?array $category, ) {} public static function fromRequest(Request $request): static @@ -51,6 +52,7 @@ class EntryData extends MicropubData syndication: Arr::get($data, 'properties.syndication.0'), photos: Arr::get($data, 'properties.photo'), postStatus: Arr::get($data, 'properties.post-status.0'), + category: Arr::get($data, 'properties.category'), ); } @@ -62,17 +64,27 @@ class EntryData extends MicropubData location: $request->input('location'), bookmarkOf: $request->input('bookmark-of'), likeOf: $request->input('like-of'), - mpSyndicateTo: $request->input('mp-syndicate-to'), + mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')), name: $request->input('name'), description: $request->input('description'), geo: $request->input('geo'), checkin: $request->input('checkin'), syndication: $request->input('syndication'), - photos: $request->input('photos'), + photos: self::wrapFormValue($request->input('photo')), postStatus: $request->input('post-status'), + category: self::wrapFormValue($request->input('category')), ); } + /** + * Form-encoded multi-value properties may be sent with or without the [] + * suffix, so a single value arrives as a string rather than an array. + */ + private static function wrapFormValue(mixed $value): ?array + { + return $value === null ? null : Arr::wrap($value); + } + private static function extractLocationData(array $data): array|string|null { if (! Arr::has($data, 'properties.location')) { @@ -104,6 +116,7 @@ class EntryData extends MicropubData syndication: $data['syndication'] ?? null, photos: $data['photos'] ?? null, postStatus: $data['post-status'] ?? null, + category: $data['category'] ?? null, ); } @@ -125,6 +138,7 @@ class EntryData extends MicropubData 'syndication' => $this->syndication, 'photos' => $this->photos, 'post-status' => $this->postStatus, + 'category' => $this->category, ]; } } diff --git a/app/Services/Micropub/MicropubHandlerRegistry.php b/app/Services/Micropub/MicropubHandlerRegistry.php index be393bf5..e6438fa9 100644 --- a/app/Services/Micropub/MicropubHandlerRegistry.php +++ b/app/Services/Micropub/MicropubHandlerRegistry.php @@ -51,7 +51,7 @@ class MicropubHandlerRegistry public function getHandler(string $type): MicropubHandlerInterface { if (! isset($this->handlers[$type])) { - throw new MicropubHandlerException("No handler registered for '{$type}'"); + throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'"); } return $this->handlers[$type]; diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index fff9a267..5c4db0d8 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -116,6 +116,26 @@ class BookmarksTest extends TestCase $response->assertJson(['response' => 'created']); Queue::assertPushed(ProcessBookmark::class); - $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); + $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); + $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); + } + + #[Test] + public function form_encoded_bookmark_with_categories_creates_tags(): void + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->getToken(), + ])->post('/api/post', [ + 'h' => 'entry', + 'bookmark-of' => 'https://example.org/blog-post', + 'category' => ['tag1', 'tag2'], + ]); + + $response->assertJson(['response' => 'created']); + + $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); + $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 5ff59844..7a350dc1 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Tests\Feature; -use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -41,7 +40,7 @@ class MicropubControllerTest extends TestCase public function micropub_get_request_without_valid_token_returns_error_response(): void { $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -203,7 +202,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJson(['error' => 'invalid_token']); } @@ -233,7 +232,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJson(['error' => 'insufficient_scope']); } @@ -456,16 +455,13 @@ class MicropubControllerTest extends TestCase 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(401); + ->assertStatus(403); } #[Test] public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void { - $this->mock(ExceptionHandler::class) - ->shouldReceive('report') - ->once() - ->with(\Mockery::type(MicropubHandlerException::class)); + $this->mock(ExceptionHandler::class)->shouldNotReceive('report'); $response = $this->postJson( '/api/post', @@ -479,10 +475,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'unsupported_operation', - 'error_description' => 'The request could not be processed by this server', + 'error' => 'invalid_request', + 'error_description' => "Unsupported Micropub request type 'unsupported'", ]) - ->assertStatus(500); + ->assertStatus(400); } #[Test] @@ -637,8 +633,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'invalid']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -676,8 +672,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'unsupported_operation']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -695,7 +691,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(401) + ->assertStatus(403) ->assertJson(['error' => 'insufficient_scope']); } @@ -1018,4 +1014,106 @@ class MicropubControllerTest extends TestCase ->assertStatus(400); $this->assertSame(1, Article::where('title', 'WireGuard')->count()); } + + #[Test] + public function micropub_client_web_request_without_h_property_creates_note(): void + { + $response = $this->post( + '/api/post', + ['content' => 'A note without an explicit type'], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201)->assertJson(['response' => 'created']); + $this->assertDatabaseHas('notes', ['note' => 'A note without an explicit type']); + } + + #[Test] + public function micropub_client_api_request_without_type_creates_note(): void + { + $response = $this->postJson( + '/api/post', + ['properties' => ['content' => ['A JSON note without an explicit type']]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201)->assertJson(['response' => 'created']); + $this->assertDatabaseHas('notes', ['note' => 'A JSON note without an explicit type']); + } + + #[Test] + public function micropub_client_web_request_can_update_note(): void + { + $note = Note::factory()->create(); + + $response = $this->post( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'replace' => ['content' => ['replaced via form']], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(200)->assertJson(['response' => 'updated']); + $this->assertSame('replaced via form', $note->refresh()->content); + } + + #[Test] + public function micropub_client_web_request_with_unsupported_action_is_not_treated_as_a_new_note(): void + { + $note = Note::factory()->create(); + $noteCount = Note::count(); + + $response = $this->post( + '/api/post', + ['action' => 'delete', 'url' => $note->uri], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function micropub_client_web_request_creates_note_with_photo(): void + { + $response = $this->post( + '/api/post', + [ + 'h' => 'entry', + 'content' => 'A note with a photo', + 'photo' => 'https://example.org/photo.jpg', + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201); + $note = Note::where('note', 'A note with a photo')->firstOrFail(); + $this->assertSame(['https://example.org/photo.jpg'], $note->media->pluck('path')->all()); + } + + #[Test] + public function micropub_client_web_request_accepts_single_syndication_target_without_brackets(): void + { + Queue::fake(); + SyndicationTarget::factory()->create([ + 'uid' => 'https://mastodon.social/@jonnybarnes', + 'service_name' => 'Mastodon', + ]); + + $response = $this->post( + '/api/post', + [ + 'h' => 'entry', + 'content' => 'Syndicate me', + 'mp-syndicate-to' => 'https://mastodon.social/@jonnybarnes', + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertStatus(201); + Queue::assertPushed(SyndicateNoteToMastodon::class); + } } diff --git a/tests/Feature/MicropubMediaTest.php b/tests/Feature/MicropubMediaTest.php index ef4fcb05..96b6031c 100644 --- a/tests/Feature/MicropubMediaTest.php +++ b/tests/Feature/MicropubMediaTest.php @@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']); } @@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_invalid_token_returns400_response(): void + public function media_endpoint_request_with_invalid_token_returns401_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_insufficient_token_scopes_returns401_response(): void + public function media_endpoint_request_with_insufficient_token_scopes_returns403_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJsonFragment([ 'error_description' => 'The token’s scope does not have the necessary requirements.', ]); -- 2.56.0 From 3dce7d3a60fa8a84bf90802a240a892f54b1131e Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Mon, 2 Nov 2026 18:59:23 +0000 Subject: [PATCH 8/9] Add Micropub q=source and reject malformed requests Servers supporting updates must support the source content query. It was falling through to the token response, so clients got a 200 with no post in it. Return the raw note as microformats2 JSON, honouring properties[]. Malformed requests could cause a 500 or worse. A replace value sent as a string overwrote the note with its first character, because $value[0] indexes the string. Validate update operations before applying any of them, and return invalid_request for wrongly shaped properties, unknown or malformed q values, and missing update URLs. Share URL resolution between updates and q=source in NoteResolver, which also rejects URLs on other hosts and invalid note IDs. Those IDs used to decode to a real note because invalid characters were skipped. Allow photo objects without alt text, and parse the published date before saving so that an invalid date doesn't leave a stray note behind. Co-Authored-By: Claude Opus 5.5 --- app/Http/Controllers/MicropubController.php | 101 ++++++++-- .../Micropub/Handlers/UpdateHandler.php | 71 ++++++- app/Services/Micropub/NoteResolver.php | 43 ++++ app/Services/Micropub/NoteSource.php | 69 +++++++ app/Services/NoteService.php | 11 +- tests/Feature/MicropubControllerTest.php | 184 ++++++++++++++++++ 6 files changed, 450 insertions(+), 29 deletions(-) create mode 100644 app/Services/Micropub/NoteResolver.php create mode 100644 app/Services/Micropub/NoteSource.php diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index ded95242..1d26894f 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -12,9 +12,12 @@ use App\Models\Place; use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\NoteResolver; +use App\Services\Micropub\NoteSource; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; class MicropubController extends Controller { @@ -46,8 +49,15 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - /** @var MicropubData $data */ - $data = $dataClass::fromRequest($request); + + try { + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); + } catch (\TypeError) { + // the data objects are typed, so this means a property has the wrong shape + return $this->invalidRequest('A property in the request has an invalid value'); + } + $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -89,40 +99,36 @@ class MicropubController extends Controller * Respond to a GET request to the micropub endpoint. * * Token validation is handled by the VerifyMicropubToken middleware. - * Supports q=syndicate-to, q=config, and q=geo:, queries. + * Supports q=syndicate-to, q=config, q=source, and q=geo:, queries. * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { - if ($request->input('q') === 'syndicate-to') { + $q = $request->input('q'); + + if ($q === 'syndicate-to') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), ]); } - if ($request->input('q') === 'config') { + if ($q === 'config') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), 'media-endpoint' => route('media-endpoint'), ]); } - if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { - preg_match_all( - '/([0-9.\-]+)/', - $request->input('q'), - $matches - ); - $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; - $places = Place::near( - (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], - $distance - )->get(); + if ($q === 'source') { + return $this->sourceResponse($request); + } - return response()->json([ - 'response' => 'places', - 'places' => $places, - ]); + if (is_string($q) && str_starts_with($q, 'geo:')) { + return $this->placesResponse($q); + } + + if ($request->has('q')) { + return $this->invalidRequest('This server does not know how to handle this q parameter'); } // the default response is just to return the token data @@ -138,4 +144,59 @@ class MicropubController extends Controller ], ]); } + + /** + * Respond to a q=source query with the microformats2 JSON of a post. + */ + private function sourceResponse(Request $request): JsonResponse + { + $url = $request->input('url'); + + if (! is_string($url)) { + return $this->invalidRequest('The url parameter is required'); + } + + try { + $note = resolve(NoteResolver::class)->fromUrl($url); + } catch (MicropubUnsupportedModelException $e) { + return $this->invalidRequest($e->getMessage()); + } catch (ModelNotFoundException) { + return $this->invalidRequest('No known note with given ID', 404); + } + + $requested = array_values(array_filter(Arr::wrap($request->input('properties')), 'is_string')); + + return response()->json(resolve(NoteSource::class)->toArray($note, $requested)); + } + + /** + * Respond to a q=geo:, query with the nearby places. + */ + private function placesResponse(string $q): JsonResponse + { + preg_match_all('/([0-9.\-]+)/', $q, $matches); + + if (count($matches[0]) < 2 || count(array_filter($matches[0], 'is_numeric')) !== count($matches[0])) { + return $this->invalidRequest('The geo query must contain a latitude and longitude'); + } + + $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near( + (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], + $distance + )->get(); + + return response()->json([ + 'response' => 'places', + 'places' => $places, + ]); + } + + private function invalidRequest(string $description, int $status = 400): JsonResponse + { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => $description, + ], $status); + } } diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 136a0840..0840e5ee 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -11,6 +11,7 @@ use App\Models\Media; use App\Models\Note; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\Data\UpdateData; +use App\Services\Micropub\NoteResolver; use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface @@ -36,18 +37,18 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url($data->updateUrl, PHP_URL_PATH); - - if (mb_substr($urlPath, 1, 5) !== 'notes') { - throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); + if ($data->updateUrl === null) { + throw new MicropubHandlerException('The url parameter is required'); } - $note = Note::nb60(basename($urlPath))->firstOrFail(); + $note = resolve(NoteResolver::class)->fromUrl($data->updateUrl); if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { throw new MicropubHandlerException('Unsupported update operation'); } + $this->validateOperations($data); + if ($data->updateReplace !== null) { foreach ($data->updateReplace as $property => $value) { match ($property) { @@ -89,6 +90,66 @@ class UpdateHandler implements MicropubHandlerInterface ]; } + /** + * Check the whole request before applying any of it, so that a malformed + * value can't leave a note partially updated. The spec requires the values + * of each property to be arrays. + * + * @throws MicropubHandlerException + */ + private function validateOperations(UpdateData $data): void + { + foreach ([$data->updateReplace ?? [], $data->updateAdd ?? []] as $operation) { + foreach ($operation as $property => $values) { + if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be a non-empty array'); + } + + $this->validateValues($property, $values); + } + } + + $delete = $data->updateDelete ?? []; + + if (array_is_list($delete)) { + foreach ($delete as $property) { + if (! is_string($property)) { + throw new MicropubHandlerException('The properties to delete must be strings'); + } + } + + return; + } + + foreach ($delete as $property => $values) { + if (! is_array($values) || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be an array'); + } + + $this->validateValues($property, $values); + } + } + + /** + * @throws MicropubHandlerException + */ + private function validateValues(string $property, array $values): void + { + if (! in_array($property, ['content', 'syndication', 'photo'], true)) { + return; + } + + foreach ($values as $value) { + if ($property === 'content' && is_array($value)) { + $value = $value['html'] ?? $value['value'] ?? null; + } + + if (! is_string($value)) { + throw new MicropubHandlerException("The {$property} property has an invalid value"); + } + } + } + private function applyDelete(Note $note, array $delete): void { if (array_is_list($delete)) { diff --git a/app/Services/Micropub/NoteResolver.php b/app/Services/Micropub/NoteResolver.php new file mode 100644 index 00000000..26d8d39a --- /dev/null +++ b/app/Services/Micropub/NoteResolver.php @@ -0,0 +1,43 @@ +firstOrFail(); + } catch (NotFoundHttpException) { + // Note::scopeNb60() aborts when the ID is too big for the database + throw (new ModelNotFoundException)->setModel(Note::class); + } + } +} diff --git a/app/Services/Micropub/NoteSource.php b/app/Services/Micropub/NoteSource.php new file mode 100644 index 00000000..457953f4 --- /dev/null +++ b/app/Services/Micropub/NoteSource.php @@ -0,0 +1,69 @@ +properties($note); + + if ($requested !== []) { + return ['properties' => (object) array_intersect_key($properties, array_flip($requested))]; + } + + return ['type' => ['h-entry'], 'properties' => (object) $properties]; + } + + private function properties(Note $note): array + { + $media = $note->media; + + return array_filter([ + // the raw source, as the note accessor returns the rendered HTML + 'content' => array_filter([$note->getRawOriginal('note')], fn ($value) => $value !== null), + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => array_filter([$note->in_reply_to]), + 'category' => $note->tags->pluck('tag')->all(), + 'photo' => $media->where('type', 'image')->map(fn ($item) => $item->pivot->alt_text + ? ['value' => $item->url, 'alt' => $item->pivot->alt_text] + : $item->url)->values()->all(), + 'video' => $media->where('type', 'video')->pluck('url')->all(), + 'audio' => $media->where('type', 'audio')->pluck('url')->all(), + 'syndication' => array_values(array_filter([ + $note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null, + $note->facebook_url, + $note->swarm_url, + $note->instagram_url, + $note->mastodon_url, + $note->bluesky_url, + ])), + 'location' => $this->location($note), + ], fn (array $values) => $values !== []); + } + + private function location(Note $note): array + { + if ($note->place !== null) { + return [$note->place->uri]; + } + + if ($note->getRawOriginal('location') !== null) { + return ['geo:'.$note->latitude.','.$note->longitude]; + } + + return []; + } +} diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index 2fbe45a2..87c4007f 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -30,6 +30,9 @@ class NoteService $content = null; } + // an invalid date throws, so parse it before anything is saved + $published = $this->getPublished($data); + $note = Note::create( [ 'note' => $content, @@ -38,7 +41,7 @@ class NoteService ] ); - if ($published = $this->getPublished($data)) { + if ($published !== null) { $note->created_at = $note->updated_at = $published; } @@ -205,11 +208,11 @@ class NoteService if (is_string($photo)) { $photoUrl = $photo; } elseif (is_array($photo)) { - $photoUrl = $photo['value']; - $photoAlt = $photo['alt']; + $photoUrl = $photo['value'] ?? null; + $photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null; } - if (empty($photoUrl)) { + if (! is_string($photoUrl) || $photoUrl === '') { continue; } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 7a350dc1..60c5ec96 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -1116,4 +1116,188 @@ class MicropubControllerTest extends TestCase $response->assertStatus(201); Queue::assertPushed(SyndicateNoteToMastodon::class); } + + #[Test] + public function source_query_returns_the_raw_note_as_microformats2(): void + { + $note = Note::factory()->create([ + 'note' => 'Some *markdown* #indieweb', + 'in_reply_to' => 'https://example.org/post', + 'mastodon_url' => 'https://mastodon.example/@me/1', + ]); + $media = Media::factory()->create(['path' => 'media/alt.jpg', 'type' => 'image']); + $note->media()->attach($media->id, ['alt_text' => 'A description', 'order' => 0]); + + $response = $this->getJson( + '/api/post?q=source&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson([ + 'type' => ['h-entry'], + 'properties' => [ + 'content' => ['Some *markdown* #indieweb'], + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => ['https://example.org/post'], + 'category' => ['indieweb'], + 'photo' => [['value' => $media->url, 'alt' => 'A description']], + 'syndication' => ['https://mastodon.example/@me/1'], + ], + ]); + } + + #[Test] + public function source_query_returns_the_location_of_a_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $geoNote = Note::factory()->create(['location' => '1.23, 4.56']); + $place = Place::factory()->create(); + $placeNote = Note::factory()->create(); + $placeNote->place()->associate($place)->save(); + + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($geoNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => ['geo:1.23,4.56']]]); + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($placeNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => [$place->uri]]]); + } + + #[Test] + public function source_query_only_returns_the_requested_properties(): void + { + $note = Note::factory()->create(['note' => 'Hello']); + + $response = $this->getJson( + '/api/post?q=source&properties[]=content&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson(['properties' => ['content' => ['Hello']]]); + } + + #[Test] + public function source_query_with_no_matching_properties_returns_an_empty_object(): void + { + $note = Note::factory()->create(); + + $response = $this->getJson( + '/api/post?q=source&properties[]=nonsense&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $this->assertSame('{"properties":{}}', $response->assertOk()->getContent()); + } + + #[Test] + public function source_query_reports_invalid_urls(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(); + + $this->getJson('/api/post?q=source', $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode('https://other.example/notes/'.$note->nb60id), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/blog/A'), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/ZZZZ'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/zzzzzzzzzz'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + } + + #[Test] + public function unknown_and_malformed_queries_return_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + + foreach (['q=nonsense', 'q[]=config', 'q=geo:junk', 'q=geo:1.2.3,4'] as $query) { + $this->getJson('/api/post?'.$query, $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + } + + #[Test] + public function update_with_a_malformed_value_is_rejected_without_changing_the_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + + $operations = [ + ['replace' => ['content' => 'hello']], + ['replace' => ['content' => []]], + ['replace' => ['content' => [123]]], + ['replace' => ['content' => ['replaced'], 'syndication' => 'https://www.swarmapp.com/1']], + ['replace' => ['content']], + ['add' => ['syndication' => [['nested']]]], + ['delete' => [['content']]], + ['delete' => ['syndication' => 'https://www.swarmapp.com/1']], + ]; + + foreach ($operations as $operation) { + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function update_with_an_unusable_url_is_rejected(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + $replace = ['replace' => ['content' => ['changed']]]; + + $this->postJson('/api/post', ['action' => 'update', ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => ['a'], ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => 'https://other.example/notes/'.$note->nb60id, ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => config('app.url').'/notes/zzzzzzzzzz', ...$replace], $headers)->assertStatus(404); + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function creating_with_a_wrongly_shaped_property_returns_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $noteCount = Note::count(); + + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'mp-syndicate-to' => 'https://a.example']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => [123]]], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->post('/api/post', ['h' => 'entry', 'content' => ['a', 'b']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_an_invalid_published_date_does_not_leave_a_note_behind(): void + { + Queue::fake(); + $noteCount = Note::count(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'published' => ['not a date']]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_a_photo_object_without_alt_text_succeeds(): void + { + Queue::fake(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'photo' => [['value' => 'https://example.org/no-alt.jpg']]]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(201); + + $this->assertDatabaseHas('media_endpoint', ['path' => 'https://example.org/no-alt.jpg']); + } } -- 2.56.0 From 5483712e2959373a24ad9ef56c5940e2db3eb462 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Mon, 2 Nov 2026 21:13:55 +0000 Subject: [PATCH 9/9] Reject Micropub updates that would do nothing Updating a property we don't support returned a 200 with no change, so clients believed the update had worked. Return invalid_request instead, and apply nothing if any part of the request is unsupported. This also covers empty operations, syndication URLs we have nowhere to store, and photos added without an https URL, all of which were being silently ignored. Mastodon and Bluesky URLs can now be added and deleted through updates, as they are our active syndication targets and q=source returns them. Mastodon can be on any host, so accept the instances we have syndication targets for rather than hard-coding one. Stop reading instagram_url in q=source, as notes have no such column. Co-Authored-By: Claude Opus 5.5 --- .../Micropub/Handlers/UpdateHandler.php | 93 +++++++++++++++---- app/Services/Micropub/NoteSource.php | 1 - tests/Feature/MicropubControllerTest.php | 75 +++++++++++++++ 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 0840e5ee..5515444d 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -9,6 +9,7 @@ use App\Exceptions\MicropubHandlerException; use App\Exceptions\MicropubUnsupportedModelException; use App\Models\Media; use App\Models\Note; +use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\Data\UpdateData; use App\Services\Micropub\NoteResolver; @@ -16,6 +17,26 @@ use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface { + /** + * The properties each update operation can change. + */ + private const SUPPORTED_PROPERTIES = [ + 'replace' => ['content', 'syndication'], + 'add' => ['syndication', 'photo'], + 'delete' => ['syndication', 'photo'], + ]; + + /** + * The syndication URLs we have somewhere to store. Mastodon URLs are also + * accepted, based on the instances in our syndication targets. + */ + private const SYNDICATION_PREFIXES = [ + 'https://www.facebook.com', + 'https://www.swarmapp.com', + 'https://twitter.com', + 'https://bsky.app', + ]; + public function dataClass(): string { return UpdateData::class; @@ -43,7 +64,7 @@ class UpdateHandler implements MicropubHandlerInterface $note = resolve(NoteResolver::class)->fromUrl($data->updateUrl); - if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { + if (! $data->updateReplace && ! $data->updateAdd && ! $data->updateDelete) { throw new MicropubHandlerException('Unsupported update operation'); } @@ -54,7 +75,6 @@ class UpdateHandler implements MicropubHandlerInterface match ($property) { 'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0], 'syndication' => $this->applySyndication($note, $value), - default => null, }; } } @@ -66,13 +86,11 @@ class UpdateHandler implements MicropubHandlerInterface } if ($property === 'photo') { foreach ($value as $photoURL) { - if (Str::startsWith($photoURL, 'https://')) { - $media = new Media; - $media->path = $photoURL; - $media->type = 'image'; - $media->save(); - $note->media()->save($media); - } + $media = new Media; + $media->path = $photoURL; + $media->type = 'image'; + $media->save(); + $note->media()->save($media); } } } @@ -91,21 +109,22 @@ class UpdateHandler implements MicropubHandlerInterface } /** - * Check the whole request before applying any of it, so that a malformed - * value can't leave a note partially updated. The spec requires the values - * of each property to be arrays. + * Check the whole request before applying any of it, so that a bad value + * can't leave a note partially updated, and a client is never told an + * update worked when we ignored it. The spec requires the values of each + * property to be arrays. * * @throws MicropubHandlerException */ private function validateOperations(UpdateData $data): void { - foreach ([$data->updateReplace ?? [], $data->updateAdd ?? []] as $operation) { - foreach ($operation as $property => $values) { + foreach (['replace' => $data->updateReplace ?? [], 'add' => $data->updateAdd ?? []] as $operation => $properties) { + foreach ($properties as $property => $values) { if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) { throw new MicropubHandlerException('The values of each property must be a non-empty array'); } - $this->validateValues($property, $values); + $this->validateProperty($operation, $property, $values); } } @@ -116,6 +135,8 @@ class UpdateHandler implements MicropubHandlerInterface if (! is_string($property)) { throw new MicropubHandlerException('The properties to delete must be strings'); } + + $this->validateProperty('delete', $property, []); } return; @@ -126,17 +147,17 @@ class UpdateHandler implements MicropubHandlerInterface throw new MicropubHandlerException('The values of each property must be an array'); } - $this->validateValues($property, $values); + $this->validateProperty('delete', $property, $values); } } /** * @throws MicropubHandlerException */ - private function validateValues(string $property, array $values): void + private function validateProperty(string $operation, string $property, array $values): void { - if (! in_array($property, ['content', 'syndication', 'photo'], true)) { - return; + if (! in_array($property, self::SUPPORTED_PROPERTIES[$operation], true)) { + throw new MicropubHandlerException("This server does not support the {$operation} operation on the {$property} property"); } foreach ($values as $value) { @@ -147,6 +168,14 @@ class UpdateHandler implements MicropubHandlerInterface if (! is_string($value)) { throw new MicropubHandlerException("The {$property} property has an invalid value"); } + + if ($property === 'syndication' && ! Str::startsWith($value, [...self::SYNDICATION_PREFIXES, ...$this->mastodonPrefixes()])) { + throw new MicropubHandlerException("This server can not store the syndication URL {$value}"); + } + + if ($operation === 'add' && $property === 'photo' && ! Str::startsWith($value, 'https://')) { + throw new MicropubHandlerException('Photos must be added using an https URL'); + } } } @@ -157,7 +186,6 @@ class UpdateHandler implements MicropubHandlerInterface match ($property) { 'syndication' => $this->clearSyndication($note), 'photo' => $note->media()->delete(), - default => null, }; } @@ -174,11 +202,28 @@ class UpdateHandler implements MicropubHandlerInterface } } + /** + * Mastodon can be on any host, so use the instances we syndicate to. + * + * @return string[] + */ + private function mastodonPrefixes(): array + { + return SyndicationTarget::where('service_name', 'Mastodon') + ->pluck('uid') + ->map(fn (string $uid) => parse_url($uid, PHP_URL_HOST)) + ->filter() + ->map(fn (string $host) => "https://{$host}/") + ->all(); + } + private function clearSyndication(Note $note): void { $note->facebook_url = null; $note->swarm_url = null; $note->tweet_id = null; + $note->mastodon_url = null; + $note->bluesky_url = null; } private function removeSyndicationValues(Note $note, array $urls): void @@ -190,6 +235,10 @@ class UpdateHandler implements MicropubHandlerInterface $note->swarm_url = null; } elseif (Str::startsWith($url, 'https://twitter.com')) { $note->tweet_id = null; + } elseif (Str::startsWith($url, $this->mastodonPrefixes()) && $note->mastodon_url === $url) { + $note->mastodon_url = null; + } elseif (Str::startsWith($url, 'https://bsky.app') && $note->bluesky_url === $url) { + $note->bluesky_url = null; } } } @@ -203,6 +252,10 @@ class UpdateHandler implements MicropubHandlerInterface $note->swarm_url = $url; } elseif (Str::startsWith($url, 'https://twitter.com')) { $note->tweet_id = basename(parse_url($url, PHP_URL_PATH)); + } elseif (Str::startsWith($url, $this->mastodonPrefixes())) { + $note->mastodon_url = $url; + } elseif (Str::startsWith($url, 'https://bsky.app')) { + $note->bluesky_url = $url; } } } diff --git a/app/Services/Micropub/NoteSource.php b/app/Services/Micropub/NoteSource.php index 457953f4..e0360e8b 100644 --- a/app/Services/Micropub/NoteSource.php +++ b/app/Services/Micropub/NoteSource.php @@ -46,7 +46,6 @@ class NoteSource $note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null, $note->facebook_url, $note->swarm_url, - $note->instagram_url, $note->mastodon_url, $note->bluesky_url, ])), diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 60c5ec96..2e33ff90 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -1241,6 +1241,81 @@ class MicropubControllerTest extends TestCase $this->assertSame('original', $note->refresh()->getRawOriginal('note')); } + #[Test] + public function update_that_would_do_nothing_is_rejected_without_changing_the_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + $mediaCount = Media::count(); + + $operations = [ + ['replace' => []], + ['replace' => ['category' => ['indieweb']]], + ['replace' => ['content' => ['changed'], 'category' => ['indieweb']]], + ['replace' => ['photo' => ['https://example.org/photo.jpg']]], + ['replace' => ['syndication' => ['https://unknown.example/1']]], + ['add' => ['category' => ['indieweb']]], + ['add' => ['content' => ['more']]], + ['add' => ['syndication' => ['https://unknown.example/1']]], + ['add' => ['photo' => ['http://example.org/photo.jpg']]], + ['delete' => ['category']], + ['delete' => ['category' => ['indieweb']]], + ['delete' => ['syndication' => ['https://unknown.example/1']]], + ]; + + foreach ($operations as $operation) { + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + $this->assertSame($mediaCount, Media::count()); + } + + #[Test] + public function update_can_add_and_delete_mastodon_and_bluesky_syndication_urls(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']); + $note = Note::factory()->create(); + $mastodon = 'https://mastodon.example/@me/1'; + $bluesky = 'https://bsky.app/profile/jonnybarnes.uk/post/1'; + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$mastodon, $bluesky]]], $headers) + ->assertOk(); + $note->refresh(); + $this->assertSame($mastodon, $note->mastodon_url); + $this->assertSame($bluesky, $note->bluesky_url); + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication' => [$mastodon]]], $headers) + ->assertOk(); + $note->refresh(); + $this->assertNull($note->mastodon_url); + $this->assertSame($bluesky, $note->bluesky_url); + + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication']], $headers) + ->assertOk(); + $this->assertNull($note->refresh()->bluesky_url); + } + + #[Test] + public function update_rejects_mastodon_urls_from_instances_we_do_not_syndicate_to(): void + { + SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']); + SyndicationTarget::factory()->create(['uid' => 'https://other.example/@me', 'service_name' => 'Pixelfed']); + $note = Note::factory()->create(['mastodon_url' => 'https://mastodon.example/@me/1']); + + foreach (['https://mastodon.social/@me/2', 'https://mastodon.example.evil/@me/2', 'https://other.example/@me/2'] as $url) { + $this->postJson( + '/api/post', + ['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$url]]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('https://mastodon.example/@me/1', $note->refresh()->mastodon_url); + } + #[Test] public function update_with_an_unusable_url_is_rejected(): void { -- 2.56.0