Skip to content

Instantly share code, notes, and snippets.

@izuolan
Last active July 6, 2023 15:15
Show Gist options
  • Save izuolan/b8fc562894fabd127590b8a0f0a615c4 to your computer and use it in GitHub Desktop.
Save izuolan/b8fc562894fabd127590b8a0f0a615c4 to your computer and use it in GitHub Desktop.
Custom domain for your Craft.do pages. Demo: https://next-craft.vercel.app and Tutorial: https://zuolan.me/en/next_craft_en
// Your domain name
const MY_DOMAIN = 'note.example.com'
// Website language
const LANG = 'en'
// Favicon url
const FAVICON_URL = 'https://example.com/favicon.ico'
// Your config page link
const CONFIG_URL = 'https://www.craft.do/s/XXXXXXXXX'
// Your Telegram Token and ID
const TG_TOKEN = ""
const TG_CHAT_ID = ""
// END
// Default function
addEventListener('fetch', event => {
event.respondWith(fetchAndApply(event.request))
})
// Fetch url
async function fetchAndApply(request) {
let url = new URL(request.url)
// Set upstream domain
url.host = 'www.craft.do'
let pathname = url.pathname
let response = null
const config_obj = await configParser()
// Automatically generate robots.txt and sitemap.xml
if (pathname === '/robots.txt') {
return new Response('Sitemap: https://' + MY_DOMAIN + '/sitemap.xml')
}
if (pathname === '/sitemap.xml') {
let sitemap = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
for(var path in config_obj){
sitemap += '<url><loc>https://' + MY_DOMAIN + '/' + path + '</loc></url>'
}
sitemap += '</urlset>'
response = new Response(sitemap)
response.headers.set('content-type', 'application/xml')
return response
}
if (pathname === '/favicon.svg') {
response = new Response('<svg xmlns="http://www.w3.org/2000/svg" width="100pt" height="100pt" viewBox="0 0 100 100"><g fill="blue" transform="translate(0.000000,100) scale(0.080000,-0.080000)"><path d="M762 1203 c-6 -15 -13 -46 -17 -68 -4 -22 -13 -49 -20 -61 -15 -23 -122 -69 -257 -109 -49 -14 -88 -28 -88 -29 0 -2 33 -20 73 -40 49 -24 87 -36 115 -36 28 0 42 -4 42 -13 0 -34 -295 -517 -390 -639 -40 -52 -4 -28 86 56 49 46 105 109 124 141 19 31 64 98 100 148 77 108 125 186 173 283 20 39 46 78 59 86 13 8 69 34 126 58 107 45 118 57 110 111 -3 21 -10 25 -78 34 l-75 10 -5 45 c-5 42 -7 45 -36 48 -26 3 -33 -1 -42 -25z"/><path d="M754 616 c-40 -19 -88 -39 -108 -46 -43 -14 -45 -30 -7 -72 25 -28 33 -31 80 -30 39 1 54 -3 58 -15 7 -18 -30 -140 -58 -192 -36 -67 6 -93 135 -84 l86 6 0 -26 c0 -14 -4 -37 -10 -51 -5 -14 -8 -26 -6 -26 7 0 110 68 129 85 11 10 17 30 17 60 0 62 -22 70 -150 57 -52 -5 -98 -6 -103 -2 -4 3 3 31 16 61 13 30 32 78 42 108 10 30 28 70 41 89 26 38 30 63 14 93 -17 31 -91 25 -176 -15z"/></g></svg>')
response.headers.set('content-type', 'image/svg+xml')
return response
}
// Default path
if (pathname === '/') {
url.pathname = '/s/' + config_obj['index'].slice(23)
}
// Prohibit other Craft.do share pages
else if (pathname.startsWith('/s/')) {
url.pathname = "/404"
}
// Proxy index blocks pages
else if (pathname.startsWith('/b/')) {
url.pathname = '/s/' + config_obj['index'].slice(23) + pathname
}
// TODO: There is an unresolved issue here
// External pages url is troublesome to deal with.
else if (pathname.includes('/x/')) {
// url.pathname = '/s/' + config_obj['index'].slice(23) + pathname
return Response.redirect('https://' + MY_DOMAIN, 301)
}
// Proxy js
else if (pathname.startsWith('/share/') && pathname.endsWith('.js')) {
response = await fetch(url)
let body = await response.text()
// replace all js files domain
response = new Response(body.replace(/www.craft.do/g, MY_DOMAIN), response)
response.headers.set('Content-Type', 'application/x-javascript')
}
// Proxy images
else if (pathname.startsWith('/img/')) {
// Proxy api.craft.do, pages preview api
// This code can proxy "MY_DOMAIN/img/<pathname>" --> "api.craft.do/render/preview/<slug>"
url.host = 'api.craft.do'
let path_name = pathname.slice(5)
let img_slug = config_obj[path_name].slice(23)
url.pathname = pathname.replace(pathname, '/render/preview/' + img_slug)
// Cache images
const cacheImage = `https://${url.host}${url.pathname}`
let response = await fetch(url.href, {
cf: {
// Always cache this fetch regardless of content type
// for a max of 86400 seconds before revalidating the resource
cacheTtl: 86400,
cacheEverything: true,
//Enterprise only feature, see Cache API for other plans
cacheKey: cacheImage,
},
})
// Reconstruct the Response object to make its headers mutable.
response = new Response(response.body, response)
// Set cache control headers to cache on browser for 25 minutes
response.headers.set("Cache-Control", "max-age=1500")
return response
}
// Disable Craft log.
else if (pathname.startsWith('/api/log/')) {
return new Response('Disable loging.')
}
// Proxy comment API.
else if (pathname.startsWith('/api/') && (pathname.includes('submitAnon'))) {
const init = {
body: request.body,
method: 'PUT',
headers: {
"content-type": "application/json;charset=UTF-8",
},
}
const response = await fetch(url.href, init)
const resp_json = await response.json()
const resp_str = JSON.stringify(resp_json)
// Telegram notify
const comment_message = resp_json.comments[0].content
const craft_slug = pathname.split("/")[5]
const craft_url = 'https://www.craft.do/s/' + craft_slug
const comment_slug = findJsonKey(config_obj, craft_url)
const tg_message = 'Comment URL:\n'
+ 'https://' + MY_DOMAIN + '/' + comment_slug
+ '\n\n'
+ 'Comment Message:\n' + comment_message
await sendToTelegram(tg_message)
return new Response(resp_str)
}
else {
try {
let urlIndexSlug = null
if (pathname.startsWith('/en/') || pathname.startsWith('/p/') || pathname.startsWith('/page/')) {
urlIndexSlug = pathname.split("/")[1] + '/' + pathname.split("/")[2]
} else {
urlIndexSlug = pathname.split("/")[1]
}
let configPath = config_obj[urlIndexSlug].slice(23)
url.pathname = '/s/' + configPath
console.log(url.pathname)
if (typeof(configPath) == "undefined") { throw new Error('404 not found: ' + configPath) }
} catch (error) {
if (pathname.startsWith('/api/') || pathname.endsWith('.css') || pathname.endsWith('.webmanifest') || pathname.endsWith('.svg')) {
// nothing
} else {
url.pathname = '/404'
// return new Response(error.message)
}
}
}
class AttributeRewriter {
element(element) {
if (element.getAttribute('property') === 'og:url') {
element.setAttribute('content', 'https://' + MY_DOMAIN + pathname)
}
if (element.getAttribute('property') === 'og:image') {
if (pathname === '/') { pathname = '/index' }
element.setAttribute('content', 'https://' + MY_DOMAIN + '/img' + pathname)
}
if (element.getAttribute('name') === 'luki:api-endpoint') {
element.setAttribute('content', 'https://' + MY_DOMAIN + '/api/')
}
if (element.getAttribute('lang') === 'en') {
element.setAttribute('lang', LANG)
}
if (element.getAttribute('rel') === 'icon') {
element.setAttribute('href', FAVICON_URL)
}
if (element.getAttribute('rel') === 'apple-touch-icon') {
element.setAttribute('href', FAVICON_URL)
}
}
}
class RemoveElement {
element(element) {
element.remove()
}
}
async function rewriteHTML(res) {
res.headers.delete("Content-Security-Policy")
return new HTMLRewriter()
.on('body', new BodyRewriter())
.on('head', new HeadRewriter())
.on('html', new AttributeRewriter())
.on('meta', new AttributeRewriter())
.on('link', new AttributeRewriter())
.on('meta[name="robots"]', new RemoveElement()) // SEO
.on('head>style', new RemoveElement()) // Remove fonts
.on('script[src="https://www.craft.do/assets/js/analytics2.js"]', new RemoveElement()) // Delete analytics js
.transform(res)
}
let method = request.method
let request_headers = request.headers
let new_request_headers = new Headers(request_headers)
new_request_headers.set('Host', url.hostname)
new_request_headers.set('Referer', url.hostname)
let original_response = await fetch(url.href, {
method: method,
headers: new_request_headers
})
let response_headers = original_response.headers
let new_response_headers = new Headers(response_headers)
let status = original_response.status
response = new Response(original_response.body, {
status,
headers: new_response_headers
})
// If you want change anything in response.
let text = await response.text()
// Return modified response.
let modified_response = new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers
})
if (pathname.startsWith('/share/static/js/') && (pathname.includes('codehighlight'))) {
return modified_response
} else {
return rewriteHTML(modified_response)
}
}
async function configParser() {
// Delete string "https://www.craft.do/s/"
let config_slug = CONFIG_URL.slice(23)
const api_url = 'https://www.craft.do/api/share/' + config_slug
const init = {
headers: {
"content-type": "application/json;charset=UTF-8",
},
}
const config_response = await fetch(api_url, init) // Get www.craft.do/api/share/<slug> content.
const response_json = await config_response.json() // Convert the content to json format (string).
const content_json = response_json.blocks[1].content // Get the json data of the first block in the body.
const content_str = JSON.stringify(content_json) // Convert json to string.
// Handle escape characters.
const config_json = content_str.replace(/\\t/g, '').replace(/\\n/g, '').replace(/\\/g, '').replace('"{', '{').replace('}"', '}')
let config_obj = JSON.parse(config_json)
return config_obj
}
async function sendToTelegram(message) {
const tgUrl = "https://api.telegram.org/bot" + TG_TOKEN + "/sendMessage"
const init = {
method: 'POST',
headers: {
"content-type": "application/json;charset=UTF-8",
},
body: JSON.stringify({
"chat_id": TG_CHAT_ID,
"text": message
})
}
await fetch(tgUrl, init)
// const response = await fetch(tgUrl, init)
// const resp_text = await response.text()
// return new Response(resp_text)
}
function findJsonKey(obj, value, compare = (a, b) => a === b) {
return Object.keys(obj).find(k => compare(obj[k], value))
}
class BodyRewriter {
element(element) {
// Append your html
element.append(`
`, {
html: true
})
}
}
class HeadRewriter {
element(element) {
element.append(`
<style>
/* Hide the Craft "Login in" button in comment board. */
.sc-CtfFt {
visibility: hidden;
}
.hGGlzy {
visibility: hidden;
}
</style>
`, {
html: true
})
}
}
@vinceimbat
Copy link

vinceimbat commented Feb 11, 2022

@izuolan Thank you for the video tutorial. But I still get this error even after following the video:

Screen Shot 2022-02-11 at 10 57 28 AM

@izuolan
Copy link
Author

izuolan commented Feb 11, 2022

@akumeilol Comments are managed by Craft, and you can disable comments on certain pages by setting in Craft.

@vinceimbat It looks like your CONFIG_URL (www.craft.do/s/9Q1QI0QrvZNnNf) does not exist, or no "index" in the json?

@vinceimbat
Copy link

@izuolan thanks! Turns out, the config URL is indeed the problem. Ok, it now works in the Cloudflare address: https://knu-salin.vinceimbat.workers.dev/. Thanks!

I proceeded to create a route as you instructed in the tutorial. It should point to: https://salin.kaliskisnaulap.com/

At first, there's the Craft rotating loading screen... But then the screen says: Something went wrong :(

Is there one last mistake I am making?

@izuolan
Copy link
Author

izuolan commented Feb 11, 2022

@vinceimbat Change MY_DOMAIN = 'salin.kaliskisnaulap.com'

:)

@vinceimbat
Copy link

Thank you @izuolan ! Everything seems to be working now (https://salin.kaliskisnaulap.com/). I will be using your solution on my personal site, which I'll update in the following days. Craft said it will introduce custom domains this year, but who knows when and how much. Your solution is the best I found for now.

The only problem I notice is that the page doesn't render the images or texts when I use Cards, showing just the outline and background of a box. That's why I converted my Card to a Page just so people see what the "blank box" is all about. Other than that, I think everything looks the same as the original craft page (and better! Because of the custom domain).

More power to your work!

@izuolan
Copy link
Author

izuolan commented Feb 11, 2022

@vinceimbat The card thumbnails take a few seconds (< 5s) to generate for the first time, and then it will be faster (< 2s).

图片

@vinceimbat
Copy link

@izuolan I used your script for my other site, the one that houses my zettelkasten. It seems that your solution only works for page links but not for wikilinks. When I click wikilinks in the paragraphs, I get this error:
Screen Shot 2022-02-19 at 12 38 47 AM

@izuolan
Copy link
Author

izuolan commented Feb 19, 2022

@vinceimbat Yes, there is an unresolved issue here.

You can remove the 73 lines of code, and it will support external documentation (wikilinks).

https://gist.github.com/izuolan/b8fc562894fabd127590b8a0f0a615c4#file-worker-js-L73

@anaclumos
Copy link

Is it possible to configure the website to apex? Cloudflare gives me the following warning:

Because CNAME records are not allowed at the zone apex (RFC 1034), CNAME flattening will be applied to this record.

Also, do you have any plans on creating a static website generator with Craft API?

@izuolan
Copy link
Author

izuolan commented Feb 20, 2022

@anaclumos Of course, once the worker triggers are set correctly, DNS resolution is an irrelevant setting.

I have not tried to generate a static website using the Craft API. But I remember that someone in Slack had posted a paid project to publish Craft pages as static pages.

@datagitateur
Copy link

datagitateur commented Mar 24, 2022

Hi everyone,
In order to share documents online with my own domain I am successfully testing this worker.
The content will not be indexed, no seo, no sitemap : I simply removed the code

// Automatically generate robots.txt and sitemap.xml
  if (pathname === '/robots.txt') {
    return new Response('Sitemap: https://' + MY_DOMAIN + '/sitemap.xml')
  }
  if (pathname === '/sitemap.xml') {
    let sitemap = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
    for(var path in config_obj){
      sitemap += '<url><loc>https://' + MY_DOMAIN + '/' + path + '</loc></url>'
    }

when I look at the code of the generated page I see robots: noindex and do not find a sitemap
Do you think this is enough?

And second question in the craft.do custom domain site in "done" section there is

Support page whitelist
Password access support (public but requires password access)

What is page Whitelist ?
How can we protect a page with password ?

Thanks in advance

@izuolan
Copy link
Author

izuolan commented Mar 24, 2022

Hi, @datagitateur

  1. I see that the noindex meta tag has been removed in demo page. The code just removed "robots" meta tag, check here.
  2. robots.txt and sitemap.xml is generated by parsing the CONFIG_URL page first line json. Delete this part of the code and it will not be generated.
  3. Whitelist means: the worker will only forward the links in the CONFIG_URL JSON, not other Craft.do pages. The advantage of this is that others will not steal your domain name.
  4. Protect a page with password: Cloudflare Worker can work with Cloudflare Access, by creating specific access policies, to protect some paths pages. For example, the code here. The "/en/", "/page/", "/p/" are my personalized path definitions. You can take other names. For example, change "/p/" to "/password/", and then create a new policy in Access, access to the path requires a password. "Password protection" provided by Cloudflare Access, but I have provided the convenience of customizing the "path" in the code.

@datagitateur
Copy link

datagitateur commented Mar 24, 2022

Thanks a lot !
I'm ok with points 1 and 2.
Point 3 has direct relation with the craft config page ?
Will investigate with access, i'm new with cloudflare, but seems i begin to understand.
One more time thanks for sharing your time and knowledge.

@moondigital
Copy link

Hi there. Is it possible to use this custom domain setup with Craft pages that have password-protection enabled? At present, I am unable to use the password to gain access to the page.

@izuolan
Copy link
Author

izuolan commented Jun 27, 2022

@moondigital This version is adapted to the password access, but this worker.js has not yet.

@shakogegia
Copy link

You need insert this somewhere to support webfonts

// Proxy fonts
  else if (pathname.startsWith('/share/') && pathname.endsWith('.woff2')) {
    response = await fetch(url)
    
    // The readable side will become our new response body.
    let { readable, writable } = new TransformStream();

    // Start pumping the body. NOTE: No await!
    response.body.pipeTo(writable);

    // ... and deliver our Response while that’s running.
    return new Response(readable, response);
  }

@gustavojellav
Copy link

Hi! Thank you for sharing this. It has helped me a lot. Is it possible that it works with the Craft analytics? Right now it does not track any visitors. Thank you!

@izuolan
Copy link
Author

izuolan commented Aug 15, 2022

Hi! Thank you for sharing this. It has helped me a lot. Is it possible that it works with the Craft analytics? Right now it does not track any visitors. Thank you!

@gustavojellav Oh, I removed the Craft analysis code by default. You can delete this script line 210, then Craft's analysis will be enabled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment