├── screenshot.png ├── public ├── favicon.png ├── manifest.json ├── favicon.svg ├── testlinks.html └── styles.css ├── views ├── error.njk ├── macro.njk ├── feed.njk ├── post.njk └── home.njk ├── package.json ├── LICENSE.md ├── tests.js ├── README.md ├── .gitignore └── app.js /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieweb/bsky.link/main/screenshot.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieweb/bsky.link/main/public/favicon.png -------------------------------------------------------------------------------- /views/error.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Error 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

There was an error fetching data from Bluesky. 18 |

{{error}} 19 |

20 | 21 | 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "bsky.link", 3 | "name": "bsky.link", 4 | "icons": [ 5 | { 6 | "src": "/favicon.svg", 7 | "type": "image/svg+xml", 8 | "sizes": "any", 9 | "purpose": "any maskable" 10 | }, 11 | { 12 | "src": "/favicon.png", 13 | "type": "image/png", 14 | "sizes": "32x32", 15 | "purpose": "any maskable" 16 | } 17 | ], 18 | "id": "/?source=pwa", 19 | "start_url": "/?source=pwa", 20 | "background_color": "#fff", 21 | "display": "standalone", 22 | "scope": "/", 23 | "theme_color": "#0060df", 24 | "description": "Create shareable links for Bluesky posts." 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bsky-preview", 3 | "version": "1.0.0", 4 | "description": "Bluesky Link Preview", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node app.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/capjamesg/bsky-link-preview.git" 13 | }, 14 | "author": "capjamesg", 15 | "license": "MIT-0", 16 | "bugs": { 17 | "url": "https://github.com/capjamesg/bsky-link-preview/issues" 18 | }, 19 | "homepage": "https://github.com/capjamesg/bsky-link-preview#readme", 20 | "dependencies": { 21 | "express": "^4.21.1", 22 | "express-async-errors": "^3.1.1", 23 | "lru-cache": "^9.1.1", 24 | "microformats-parser": "^1.4.1", 25 | "node-fetch": "^3.3.1", 26 | "nunjucks": "^3.2.4", 27 | "nunjucks-date-filter": "^0.1.1", 28 | "xmlhttprequest": "^1.8.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2023 capjamesg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tests.js: -------------------------------------------------------------------------------- 1 | const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); 2 | const { mf2 } = require("microformats-parser"); 3 | 4 | const ROOT_URL = "http://localhost:3008/?url="; 5 | 6 | var urls = [ 7 | "https://staging.bsky.app/profile/pfrazee.com/post/3juxla7zr762b", 8 | "https://bsky.app/profile/dholms.xyz/post/3juyy5bpik52h", 9 | "https://bsky.app/profile/nowah.bsky.social/post/3juti6hxske24", 10 | "https://bsky.app/profile/tudorgirba.com/post/3jutyyruobi22" 11 | ]; 12 | 13 | for (var i = 0; i < urls.length; i++) { 14 | var url = ROOT_URL + urls[i]; 15 | fetch(url, { 16 | method: "GET", 17 | }).then((response) => { 18 | var url = response.url; 19 | response.text().then((data) => { 20 | var mf2Data = mf2(data, {baseUrl: url}); 21 | if (mf2Data.items.length == 0) { 22 | throw Error("No mf2 data found for " + url); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | console.log("All tests passed!"); -------------------------------------------------------------------------------- /public/testlinks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | test links 4 | 5 | 6 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bluesky Link Preview 2 | 3 | Generate permalinks to Bluesky posts that you can embed in a website. 4 | 5 | Posts are not cached. This means that if a post becomes unavailable or private, the link will no longer work. 6 | 7 | Text posts, posts with embedded posts, and posts that contain one or more images are supported by this project. 8 | 9 | _This project is not affiliated with [Bluesky](https://bsky.app)._ 10 | 11 | ## Screenshot 12 | 13 | ![Screenshot of a Bluesky post written by jamesg.blog](screenshot.png) 14 | 15 | ## How to Embed 16 | 17 | To create an embedded link, go to [https://bsky.link](https://bsky.link) and enter the URL of the Bluesky post you want to embed. The application will generate an iframe that you can embed in your site. 18 | 19 | You can also reference a link directly in your own `iframe` using this syntax: 20 | 21 | ``` 22 | 23 | ``` 24 | 25 | ## Application Routes 26 | 27 | - `/`: Interactive web application through which you can generate embed codes. 28 | - `/?url=`: Endpoint with a post that you can embed into a website. 29 | 30 | ## How to Run 31 | 32 | To run the application, first install the required dependencies: 33 | 34 | ``` 35 | npm install 36 | ``` 37 | 38 | Then, create `config.js` and add your Bluesky credentials (handle and password) and domain name for the Bluesky Link application, like this: 39 | ``` 40 | const config = { 41 | "HANDLE":"yourname.bsky.social", 42 | "PASSWORD":"xxxx-xxxx-xxxx-xxxx", 43 | "DOMAIN":"bsky.domain.example" 44 | } 45 | module.exports = config; 46 | ``` 47 | 48 | Finally, run the following command to start the application server: 49 | 50 | ``` 51 | npm start 52 | ``` 53 | 54 | ## License 55 | 56 | This project is licensed under an [MIT 0 License](LICENSE). 57 | 58 | ## Contributors 59 | 60 | - capjamesg 61 | - kevinmarks 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | config.js -------------------------------------------------------------------------------- /views/macro.njk: -------------------------------------------------------------------------------- 1 | {% macro time_ago(t) %} 2 | {% if t %} 3 | 4 | {% endif %} 5 | {% endmacro %} 6 | 7 | {% macro time_local(t) %} 8 | {% if t %} 9 | 10 | {% endif %} 11 | {% endmacro %} 12 | 13 | {% macro post_actions(p) %} 14 |
15 | ❤️ {{ p.likeCount }} likes 16 | 🔄 {{ p.repostCount }} reposts 17 | 💬 {{ p.replyCount }} replies 18 |
19 | {% endmacro %} 20 | 21 | {% macro author(p,t) %} 22 |
23 | {{ p.author.displayName }}'s profile picture 24 |
25 |

{{ p.author.displayName }}

26 | 🔵{{ p.author.handle }} 27 |
28 | {{t}} 29 |
30 | {% endmacro %} 31 | 32 | {% macro post_text(p) %} 33 | {{ p.record |linkify_text|safe }} 34 | {% endmacro %} 35 | 36 | {% macro embed_images(e) %} 37 | {% for image in e.images %} 38 | {{ image.alt }} 39 | {% endfor %} 40 | {% endmacro %} 41 | 42 | {% macro embed_record(e) %} 43 |
44 | {{author (e.record,post_link(e.record,time_ago(e.record.value.createdAt)))}} 45 |
46 | {{ e.record.value.text }} 47 |
48 | {% for embed2 in e.record.embeds %} 49 | {{embed_images(embed2)}} 50 | {% endfor %} 51 |
52 | {% endmacro %} 53 | 54 | {% macro embeds(p) %} 55 | {% if (p.embed and p.embed.media) %} 56 | {{embed_images(p.embed.media)}} 57 | {{embed_record(p.embed.record)}} 58 | {% elif (p.embed and p.embed.record) %} 59 | {{embed_record(p.embed)}} 60 | {% elif (p.embed and p.embed.images ) %} 61 | {{embed_images(p.embed)}} 62 | {% elif (p.embed and p.embed.external) %} 63 |
64 | {% if p.embed.external.thumb %} 65 | {{ p.embed.external.title }} 66 | {% endif %} 67 |
68 |

{% if p.embed.external.title %}{{ p.embed.external.title }}{% else %}{{p.embed.external.uri}}{% endif %}

69 |

{{ p.embed.external.description }}

70 |
71 |
72 | {% endif %} 73 | {% endmacro %} 74 | 75 | {% macro post_url(p) -%} 76 | https://bsky.app/profile/{{p.author.handle}}/post/{{p.uri|last_path}} 77 | {%- endmacro %} 78 | 79 | {% macro post_link (post,linktext) -%} 80 | {{linktext}} 81 | {%- endmacro %} -------------------------------------------------------------------------------- /views/feed.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Posts by {{author}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% import "macro.njk" as macro %} 25 | 26 | 27 |
28 | 68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /views/post.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Post by {{ thread.post.author.displayName }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% if (thread.post.embed and thread.post.embed.images ) %} 21 | 22 | 23 | 24 | {% endif %} 25 | 26 | 27 | 28 | 29 | 30 | 35 | 36 | {% import "macro.njk" as macro %} 37 | 38 |
39 | {% if (thread.parent and not hide_parent) %} 40 |
41 | {{macro.author (thread.parent.post)}} 42 |
43 | {{macro.post_text(thread.parent.post)}} 44 | {{macro.embeds(thread.parent.post)}} 45 |
46 | {{macro.post_link (thread.parent.post,macro.time_ago(thread.parent.post.record.createdAt))}} 47 | {{macro.post_actions(thread.parent.post)}} 48 |
49 |
50 | {% endif %} 51 |
52 | {{macro.author (thread.post)}} 53 |
54 | {{macro.post_text(thread.post)}} 55 | {{ macro.embeds(thread.post)}} 56 |
57 | {{macro.post_link (thread.post,macro.time_local(thread.post.record.createdAt))}} 58 | {{macro.post_actions(thread.post)}} 59 |
60 | {% if show_thread %} 61 | {% for reply in flat_replies %} 62 |
63 |
64 | {{macro.author (reply.post)}} 65 |
66 | {{macro.post_text(reply.post)}} 67 | {{macro.embeds(reply.post)}} 68 |
69 | {{macro.post_link (reply.post,macro.time_ago(reply.post.record.createdAt))}} 70 | {{macro.post_actions(reply.post)}} 71 |
72 |
73 | {% endfor %} 74 | Show Full Thread 75 | {% endif %} 76 |
77 | 78 | 79 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 3 | box-sizing: border-box; 4 | } 5 | html { 6 | padding: 10px; 7 | border-top: 5px solid #0060df; 8 | } 9 | img { 10 | border-radius: 50%; 11 | } 12 | .author { 13 | display: flex; 14 | align-items: center; 15 | } 16 | .inner-embed .author img { 17 | height:1em; 18 | width:1em; 19 | } 20 | .inner-embed .author h2 { 21 | display: inline; 22 | font-size: 1em; 23 | } 24 | .inner-embed .author p { 25 | display: inline; 26 | } 27 | 28 | .inner-embed { 29 | border: 1px solid lightgray; 30 | border-radius: 8px; 31 | padding: 0.5em; 32 | } 33 | .author-name h2, .author-name p { 34 | margin: 0; 35 | } 36 | .author-name { 37 | margin-left: 10px; 38 | } 39 | blockquote { 40 | margin-left: 0; 41 | padding: 0; 42 | border-left: 5px solid #ccc; 43 | padding-left: 10px; 44 | overflow-wrap: break-word; 45 | word-wrap: break-word; 46 | } 47 | .e-content { 48 | white-space: break-spaces; 49 | } 50 | main { 51 | max-width: 500px; 52 | } 53 | iframe { 54 | margin-top: 10px; 55 | border: 1px solid #ccc; 56 | } 57 | a { 58 | color: #0060df; 59 | text-decoration: none; 60 | } 61 | .inner-embed { 62 | margin-top: 25px; 63 | } 64 | .embedded-image { 65 | height: 100%; 66 | width: 100%; 67 | border-radius: 10px; 68 | object-fit: cover; 69 | margin-top: 25px; 70 | } 71 | 72 | input[type="url"], textarea { 73 | padding: 0.5em; 74 | border: 1px solid #ccc; 75 | border-radius: 0.25em; 76 | font-size: 1em; 77 | width: 100%; 78 | } 79 | input[type="radio"] { 80 | margin-right: 5px; 81 | } 82 | button { 83 | background: #0060df; 84 | color: #fff; 85 | border: none; 86 | cursor: pointer; 87 | padding: 10px; 88 | border-radius: 10px; 89 | margin-top: 10px; 90 | font-size: 1em; 91 | } 92 | .datelink { 93 | color: black; 94 | font-weight: 200; 95 | text-decoration: none; 96 | font-size: 0.8em; 97 | margin-bottom: 10px; 98 | } 99 | ul { 100 | padding: 0; 101 | list-style: none; 102 | } 103 | li, .reposted-by { 104 | margin-bottom: 30px; 105 | } 106 | hr { 107 | border: 0; 108 | border-top: 1px solid #ccc; 109 | margin: 25px 0; 110 | } 111 | pre { 112 | background: #eee; 113 | padding: 10px; 114 | border-radius: 10px; 115 | overflow-x: scroll; 116 | } 117 | code { 118 | background: #eee; 119 | } 120 | .indent { 121 | margin-left: 20px; 122 | } 123 | .indent_twice { 124 | margin-left: 40px; 125 | } 126 | .external-embed { 127 | margin-top: 25px; 128 | border: 1px solid #ccc; 129 | border-radius: 10px; 130 | } 131 | .external-embed img { 132 | margin-top: 0; 133 | border-bottom-left-radius: 0%; 134 | border-bottom-right-radius: 0%; 135 | } 136 | .external-embed .text-card { 137 | padding: 10px; 138 | } 139 | .external-embed .text-card .title { 140 | font-size: 1.2em; 141 | margin-top: 0; 142 | } 143 | .external-embed .text-card .desc { 144 | font-size: 0.8em; 145 | margin-top: 0; 146 | } 147 | @media screen and (max-width: 500px) { 148 | input[type="url"], textarea, input[type="submit"] { 149 | width: 100%; 150 | } 151 | } 152 | p { 153 | line-height: 1.5; 154 | } 155 | 156 | menu { 157 | padding: 0; 158 | } 159 | menu a { 160 | background: white; 161 | color: #0060df; 162 | border: none; 163 | border-bottom: 3px solid lightgrey; 164 | padding: 0; 165 | border-radius: 0; 166 | padding: 0.5em; 167 | padding-bottom: 5px; 168 | } 169 | footer { 170 | margin-top: 50px; 171 | } 172 | button:focus, menu a:focus { 173 | outline: none; 174 | background: yellow; 175 | color: black; 176 | border: 1px solid black; 177 | } 178 | 179 | menu a.active { 180 | outline: none; 181 | background: #0060df; 182 | color: white; 183 | } 184 | .tab { display: none; } 185 | .tab:target { display: block; } 186 | .tab:last-child { display: block; } 187 | .tab:target ~ .tab:last-child { display: none; } -------------------------------------------------------------------------------- /views/home.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{domain}}: Embed Bluesky Posts and Feeds 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |

{{domain}}

30 | 31 |

Generate embeddable links for Bluesky Posts.

32 |
33 |
34 | 35 | Embed Post 36 | Generate Feed 37 | Resolve DID 38 | 39 |

Generate Feeds for User Profiles

40 |

if you want an RSS Feed of all the people you follow on Bluesky, use bluesky-atom.appspot.com instead. 41 |

42 |

43 | 44 |
45 | 46 |
47 | 48 |

You can generate feeds for user profiles using the following syntax:

49 | 50 |
https://{{domain}}/feed?user=<handle>
51 | 52 |

Where <handle> is the handle of the user (i.e. jamesg.blog).

53 |
54 | 55 |
56 | 57 | Embed Post 58 | Generate Feed 59 | Resolve DID 60 | 61 |

Resolve a DID to Username

62 | 63 |
64 |

65 | 66 |
67 | 68 |
69 | 70 |

You can resolve a DID using the following syntax:

71 | 72 |
https://{{domain}}/getfeed?handle=<did>
73 | 74 |

Where <did> is the DID you want to resolve.

75 |
76 |
77 | 78 | Embed Post 79 | Resolve DID 80 | 81 |
82 |

83 | 84 | 85 | 86 | 87 | 88 |

89 | 90 |
91 | 92 |
93 | 94 |
95 | 96 |
97 | 98 |

How To Create Links

99 | 100 |

You can create links to share using the following syntax:

101 | 102 |
https://{{domain}}/?url=https://bsky.app/profile/<handle>.bsky.social/post/<post_id>
103 | 104 |

Where <handle> is the handle of the author of the post, and <post_id> is the ID of the post.

105 | 106 |

Specify the show_thread parameter to show the thread of replies to the post. Set it to t to show all the posts a user has added to a thread.

107 |
108 |
109 | 112 |
113 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const express = require("express"); 3 | require('express-async-errors'); 4 | const { LRUCache } = require("lru-cache"); 5 | const nunjucks = require('nunjucks'); 6 | const dateFilter = require('nunjucks-date-filter'); 7 | 8 | const config = require("./config.js"); 9 | const domain = config.DOMAIN ?? "bsky.link"; 10 | 11 | const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); 12 | 13 | const PORT = process.env.PORT || 3008; 14 | const VALID_URLS = ["bsky.app", "staging.bsky.app"]; 15 | 16 | const debug_log = true; 17 | 18 | function log(s) { 19 | if(debug_log){ 20 | console.log(s) 21 | } 22 | } 23 | 24 | const app = express(); 25 | 26 | app.use(express.static("public")); 27 | app.use((err, req, res, next) => { 28 | res.status(500).render("error.njk", { 29 | error: "There was an error loading this page." 30 | }); 31 | }); 32 | const nun_env = nunjucks.configure('views', { 33 | autoescape: true, 34 | 'express': app 35 | }); 36 | nun_env.addGlobal('domain', domain); 37 | nun_env.addFilter('date', dateFilter); 38 | 39 | nun_env.addFilter('last_path', function(str) { 40 | return str.split('/').pop(); 41 | }); 42 | 43 | nun_env.addFilter('linkify_text', function(r) { 44 | const encoder = new TextEncoder(); 45 | let decoder = new TextDecoder(); 46 | const text_bytes = encoder.encode(r.text); 47 | const textchunks = []; 48 | let last_offset=0; 49 | if (r.facets){ 50 | for (const facet of r.facets){ 51 | textchunks.push(decoder.decode(text_bytes.slice(last_offset,facet.index.byteStart))); 52 | let closeLink=false; 53 | for (const f of facet.features){ 54 | if (f.uri){ 55 | textchunks.push(``); 56 | closeLink = true; 57 | break; 58 | } 59 | } 60 | textchunks.push(decoder.decode(text_bytes.slice(facet.index.byteStart,facet.index.byteEnd))); 61 | last_offset=facet.index.byteEnd; 62 | if (closeLink){ 63 | textchunks.push(""); 64 | } 65 | } 66 | } 67 | textchunks.push(decoder.decode(text_bytes.slice(last_offset))); 68 | return textchunks.join(''); 69 | }); 70 | let token = ""; 71 | let auth_token_expires = new Date().getTime(); 72 | let refresh = ""; 73 | 74 | function getAuthToken () { 75 | log("getAuthToken called "); 76 | return fetch("https://bsky.social/xrpc/com.atproto.server.createSession", { 77 | method: "POST", 78 | headers: { 79 | "Content-Type": "application/json", 80 | }, 81 | body: JSON.stringify({ 82 | "identifier": config.HANDLE, 83 | "password": config.PASSWORD 84 | }) 85 | }) 86 | .then (response => { 87 | log(`getAuthToken status:'${response.status}' statusText:'${response.statusText}';`) 88 | return response.json().then((data) => { 89 | //log(data); 90 | if (response.status==200) { 91 | token = data.accessJwt; 92 | refresh = data.refreshJwt; 93 | log(`getAuthToken response: token='${token}'; refresh='${refresh}';`) 94 | auth_token_expires = new Date().getTime() + 1000 *5 * 60 * 30; 95 | } else { 96 | auth_token_expires = new Date().getTime(); 97 | } 98 | }) 99 | }) 100 | .catch(err =>{ 101 | //catch err 102 | console.log(err); 103 | }); 104 | } 105 | 106 | function refreshAuthToken () { 107 | if (refresh==="") { 108 | log("refreshAuthToken called without refresh token;"); 109 | return getAuthToken(); 110 | } 111 | log("refreshAuthToken called with: token='"+token+"'; refresh='"+refresh+"';"); 112 | return fetch("https://bsky.social/xrpc/com.atproto.server.refreshSession", { 113 | method: "POST", 114 | headers: { 115 | "Content-Type": "application/json", 116 | "Authorization": `Bearer ${refresh}` 117 | } 118 | }) 119 | .then (response => { 120 | log(`refreshAuthToken status:'${response.status}' statusText:'${response.statusText}';`) 121 | return response.json().then((data) => { 122 | //log(data); 123 | if (response.status==200) { 124 | token = data.accessJwt; 125 | refresh = data.refreshJwt; 126 | auth_token_expires = new Date().getTime() + 1000 *5 * 60 * 30; 127 | log(`refreshAuthToken response: token='${token}'; refresh='${refresh}';`) 128 | } else { 129 | return getAuthToken(); 130 | } 131 | }) 132 | }) 133 | .catch(err =>{ 134 | //catch err 135 | console.log(err); 136 | }); 137 | } 138 | 139 | function flattenReplies (replies, author_handle, iteration = 0) { 140 | // replies are nested objects of .replies, so we need to flatten them 141 | let all_replies = []; 142 | 143 | if (iteration > 20) { 144 | return all_replies; 145 | } 146 | 147 | if (!replies || replies.length == 0) { 148 | return all_replies; 149 | } 150 | 151 | for (const reply of replies) { 152 | if (reply?.post?.author?.handle != author_handle) { 153 | continue; 154 | } 155 | 156 | all_replies.push(reply); 157 | 158 | if (reply.replies && reply.replies.length > 0) { 159 | all_replies = all_replies.concat(flattenReplies(reply.replies, author_handle, iteration + 1)); 160 | } 161 | } 162 | 163 | return all_replies; 164 | } 165 | 166 | const options = { 167 | max: 500, 168 | 169 | // for use with tracking overall storage size 170 | maxSize: 5000, 171 | sizeCalculation: (value, key) => { 172 | return 1 173 | }, 174 | 175 | // how long to live in ms 176 | ttl: 1000 * 60 * 5, 177 | 178 | // return stale items before removing from cache? 179 | allowStale: false, 180 | 181 | updateAgeOnGet: false, 182 | updateAgeOnHas: false, 183 | } 184 | 185 | const cache = new LRUCache(options); 186 | 187 | app.route("/").get(async (req, res) => { 188 | if (new Date().getTime() > auth_token_expires) { 189 | await refreshAuthToken(); 190 | } 191 | 192 | const url = req.query.url; 193 | let parsed_url; 194 | 195 | if (!url) { 196 | // show home 197 | res.render("home.njk"); 198 | return; 199 | } 200 | 201 | try { 202 | parsed_url = new URL(url); 203 | } catch (e) { 204 | res.render("error.njk", { 205 | error: `Invalid URL: '${url}'` 206 | }); 207 | return; 208 | } 209 | 210 | const domain = parsed_url.hostname; 211 | 212 | if (!VALID_URLS.includes(domain)) { 213 | res.render("error.njk", { 214 | error: `Not a known Bluesky host '${domain}'` 215 | }); 216 | return; 217 | } 218 | 219 | const show_thread = (req.query.show_thread == "on") || (req.query.show_thread == "t"); //support legacy value of 't' for existing URLs 220 | const hide_parent = req.query.hide_parent == "on"; 221 | 222 | const handle = parsed_url.pathname.split("/")[2]; 223 | const post_id = parsed_url.pathname.split("/")[4]; 224 | 225 | if (!handle || !post_id) { 226 | res.render("error.njk", { 227 | error: `Empty handle '${handle}' or post '${post_id}'` 228 | }); 229 | return; 230 | } 231 | let query_parts = []; 232 | if (show_thread){ 233 | query_parts.push("show_thread=on"); 234 | } 235 | if (hide_parent){ 236 | query_parts.push("hide_parent=on"); 237 | } 238 | query_parts.push(`url=${url}`); 239 | const query_string = "?" + query_parts.join("&"); 240 | if (cache.has(query_string)) { 241 | const data = cache.get(query_string); 242 | res.render("post.njk", data); 243 | return; 244 | } 245 | // log("resolveHandle fetch: token='"+token+"'; refresh='"+refresh+"';"); 246 | return fetch("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + handle, { 247 | method: "GET", 248 | headers: { 249 | "Content-Type": "application/json", 250 | "Authorization": "Bearer " + token 251 | } 252 | }).then((response) => { 253 | log("resolveHandle fetch: status='"+response.status+"'; statusText='"+response.statusText+"';"); 254 | response.json().then((did) => { 255 | // log("getPostThread fetch: token='"+token+"'; refresh='"+refresh+"';"); 256 | fetch(`https://bsky.social/xrpc/app.bsky.feed.getPostThread?uri=at://${did.did}/app.bsky.feed.post/${post_id}`, { 257 | method: "GET", 258 | headers: { 259 | "Content-Type": "application/json", 260 | "Authorization": "Bearer " + token 261 | }, 262 | }).then((response) => { 263 | log("getPostThread fetch: status='"+response.status+"'; statusText='"+response.statusText+"';"); 264 | if (response.status !=200){ 265 | auth_token_expires = new Date().getTime(); 266 | res.render("error.njk", { 267 | error: "Connection problem, try reloading" 268 | }); 269 | return; 270 | } 271 | 272 | response.json().then((data) => { 273 | 274 | if (data && data.thread && data.thread.post) { 275 | // eschew preprocessing 276 | } else { 277 | res.render("error.njk", { 278 | error: "No thread or post" 279 | }); 280 | return; 281 | } 282 | 283 | const author_handle = data.thread.post?.author?.handle; 284 | 285 | const flat_replies = flattenReplies(data.thread.replies, author_handle); 286 | 287 | const response_data = { 288 | thread: data.thread, 289 | url: "https://"+domain+"/" + query_string, 290 | post_url: url, 291 | show_thread: show_thread, 292 | hide_parent: hide_parent, 293 | flat_replies: flat_replies, 294 | }; 295 | 296 | cache.set(query_string, response_data); 297 | 298 | res.render("post.njk", response_data); 299 | }); 300 | }); 301 | }); 302 | }).catch(err =>{ 303 | console.log(err); 304 | res.send("Err
"+JSON.stringify(err,null,'')+"
") 305 | }); 306 | }); 307 | 308 | // app.route("/feed").get(async (req, res) => { 309 | // if (new Date().getTime() > auth_token_expires) { 310 | // await refreshAuthToken(); 311 | // } 312 | 313 | // const user = req.query.user?req.query.user.toLowerCase():''; 314 | 315 | // if (!user) { 316 | // res.redirect('/#emcode'); 317 | // return; 318 | // } 319 | // // log("getAuthorFeed fetch: token='"+token+"'; refresh='"+refresh+"';"); 320 | // return fetch("https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=" + user, { 321 | // method: "GET", 322 | // headers: { 323 | // "Content-Type": "application/json", 324 | // "Authorization": "Bearer " + token 325 | // } 326 | // }).then((response) => { 327 | // log("getAuthorFeed fetch: status='"+response.status+"'; statusText='"+response.statusText+"';"); 328 | // if (response.status !=200){ 329 | // auth_token_expires = new Date().getTime(); 330 | // res.render("error.njk", { 331 | // error: "Connection problem, try reloading" 332 | // }); 333 | // return; 334 | // } 335 | // response.json().then((data) => { 336 | 337 | // res.render("feed.njk", { 338 | // author: user, 339 | // posts: data.feed, 340 | // }); 341 | // }); 342 | // }).catch(err =>{ 343 | // console.log(err); 344 | // res.send("Err
"+JSON.stringify(err,null,'')+"
") 345 | // }); 346 | // }); 347 | 348 | app.route("/getfeed").get(async (req, res) => { 349 | if (new Date().getTime() > auth_token_expires) { 350 | await refreshAuthToken(); 351 | } 352 | 353 | var handle = req.query.handle; 354 | 355 | if (!handle) { 356 | res.status(400).send("No handle provided"); 357 | return; 358 | } 359 | 360 | if (handle.startsWith("https://staging.bsky.app/profile/")) { 361 | handle = handle.replace("https://staging.bsky.app/profile/", ""); 362 | } else if (handle.startsWith("https://bsky.app/profile/")) { 363 | handle = handle.replace("https://bsky.app/profile/", ""); 364 | } 365 | 366 | handle = handle.replace("/", ""); 367 | 368 | if (handle.startsWith("did:")) { 369 | return fetch("https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=" + handle, { 370 | method: "GET", 371 | headers: { 372 | "Content-Type": "application/json", 373 | "Authorization": "Bearer " + token 374 | } 375 | }).then((response) => { 376 | response.json().then((did) => { 377 | res.json({ 378 | "handle": did.handle 379 | }); 380 | }); 381 | }).catch(err =>{ 382 | console.log(err); 383 | res.send("Err
"+JSON.stringify(err,null,'')+"
") 384 | }); 385 | } else { 386 | res.json({ 387 | "handle": handle 388 | }); 389 | } 390 | }); 391 | 392 | // app.route("/profile/:userHandle/").get(async (req, res) => { 393 | // log(req.params); 394 | // res.redirect(`/feed?user=${req.params.userHandle}`) 395 | // }); 396 | 397 | app.route("/profile/:userHandle/post/:postId").get(async (req, res) => { 398 | log(req.params); 399 | res.redirect(`/?url=https://bsky.app/profile/${req.params.userHandle}/post/${req.params.postId}`) 400 | }); 401 | 402 | // run in production mode 403 | app.listen(PORT, () => { 404 | console.log("Server started on port " + PORT); 405 | }); 406 | 407 | process.on('uncaughtException', async (err) => { 408 | console.log(err); 409 | }); 410 | --------------------------------------------------------------------------------