« get me outta code hell

scratchrlol - Simple HTML-based Scratch client
summary refs log tree commit diff
path: root/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'index.js')
-rwxr-xr-xindex.js443
1 files changed, 443 insertions, 0 deletions
diff --git a/index.js b/index.js
new file mode 100755
index 0000000..bd4208e
--- /dev/null
+++ b/index.js
@@ -0,0 +1,443 @@
+const fetch = require('node-fetch')
+const fixWS = require('fix-whitespace')
+const fs = require('fs')
+const http = require('http')
+const qs = require('querystring')
+const url = require('url')
+const util = require('util')
+
+const readFile = util.promisify(fs.readFile)
+
+// General page size.
+const limit = 20
+
+const page = async (request, response, text) => {
+  const cookies = parseCookies(request)
+
+  const notificationCount = await getMessageCount(cookies.username)
+
+  response.setHeader('Content-Type', 'text/html')
+  response.end(fixWS`
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <meta charset="utf-8">
+        <title>Scratch</title>
+        <link rel="stylesheet" href="/style.css">
+      </head>
+      <body>
+        <header>
+          ${cookies.username ? fixWS`
+            <p>You are logged in as ${cookies.username}. (<a href="/logout">Log out.</a>) You have ${'' + notificationCount} unread <a href="/notifications">${pluralize('notification', notificationCount)}</a>.</p>`
+          : fixWS`
+            <p>You are not logged in. <a href="/login">Log in.</a></p>
+          `}
+          <hr>
+        </header>
+        ${text}
+      </body>
+    </html>
+  `)
+}
+
+const getMessageCount = function(username) {
+  return fetch(`https://api.scratch.mit.edu/proxy/users/${username}/activity/count`)
+    .then(res => res.json())
+    .then(foo => foo.msg_count)
+}
+
+const getNotifications = function(username, token, pageNumber = 1) {
+  const offset = (pageNumber - 1) * limit
+  return fetch(`https://api.scratch.mit.edu/users/${username}/messages?x-token=${token}&limit=${limit}&offset=${offset}`)
+    .then(res => res.json())
+}
+
+const markNotificationsAsRead = function(sessionID, csrfToken) {
+  return fetch(`https://scratch.mit.edu/site-api/messages/messages-clear/`, {
+    method: 'POST',
+    headers: {
+      // CSRF token must be specified in both header and cookie!
+      Cookie: `scratchsessionsid="${sessionID}"; scratchcsrftoken=${csrfToken}`,
+      Referer: 'https://scratch.mit.edu/messages/',
+      'X-CSRFToken': csrfToken
+    }
+  }).then(res => res.text()).then(text => console.log(text))
+}
+
+const getProject = function(projectID, token) {
+  return fetch(`https://api.scratch.mit.edu/projects/${projectID}?x-token=${token}`)
+    .then(res => res.json())
+}
+
+const getUser = function(username) {
+  return fetch(`https://api.scratch.mit.edu/users/${username}`)
+    .then(res => res.json())
+}
+
+const last = arr => arr[arr.length - 1]
+const filterHTML = text => text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
+const pluralize = (word, n) => n === 1 ? word : word + 's'
+
+const templates = {
+  user: username => `<a href="/users/${username}">${username}</a>`,
+  project: (name, id) => `<a href="/projects/${id}">${filterHTML(name.trim())}</a>`,
+  studio: (name, id) => `<a href="/studios/${id}">${filterHTML(name.trim())}</a>`,
+  forumThread: (name, id) => `<a href="/topics/${id}">${filterHTML(name.trim())}</a>`,
+  longField: text => {
+    return text.split('\n')
+      .map(filterHTML)
+      .reduce((arr, l, i, lines) =>
+        [...arr.slice(0, -1), ...l.trim().length
+          ? (last(arr).length
+            ? [last(arr) + '<br>\n   ' + l]
+            : ['<p>' + l]).map(x => i === lines.length - 1 ? x + '</p>' : x)
+          : last(arr).length
+            ? [last(arr) + '</p>', '']
+            : ['']],
+        [''])
+      .map(line => line.replace(/@([a-zA-Z0-9\-_]+)/g, (m, username) => fixWS`
+        <a href="/users/${username}">@${username}</a>
+      `))
+      .map(line => line.replace(/https?:\/\/([^."<>\s]\.)*scratch\.mit\.edu(\/[^"<>\s]*)/g, (url, subdomain, path) => {
+        const text = url
+        if (!subdomain) {
+          if (path.startsWith('/projects/')) {
+            url = path
+          } else if (path.startsWith('/users/')) {
+            url = path
+          }
+        }
+        return fixWS`
+          <a href="${url}">${text}</a>
+        `
+      }))
+      .join('\n')
+  },
+
+  notification: (notif, username) => {
+    let text = '<li>'
+
+    if (notif.type === 'addcomment') {
+      text += templates.user(notif.actor_username)
+      text += ` wrote a comment `
+      if (notif.comment_type === 0) {
+        text += 'on ' + templates.project(notif.comment_obj_title, notif.comment_obj_id)
+      } else if (notif.comment_type === 1) {
+        if (notif.comment_obj_title === username) {
+          text += `on <a href="/users/${notif.comment_obj_title}">your profile</a>`
+        } else {
+          text += `on <a href="/users/${notif.comment_obj_title}">${notif.comment_obj_title}'s profile</a>`
+        }
+      } else if (notif.comment_type === 2) {
+        text += 'in ' + templates.studio(notif.comment_obj_title, notif.comment_obj_id)
+      } else {
+        text += `somewhere I don't recognize (${notif.comment_type})`
+      }
+    } else if (notif.type === 'loveproject' || notif.type === 'favoriteproject') {
+      text += templates.user(notif.actor_username)
+      if (notif.type === 'loveproject') {
+        text += ` loved `
+      } else {
+        text += ` favorited `
+      }
+      text += `your project `
+      text += templates.project(notif.title || notif.project_title, notif.project_id)
+    } else if (notif.type === 'remixproject') {
+      text += templates.user(notif.actor_username)
+      text += ` remixed your project `
+      text += templates.project(notif.parent_title, notif.parent_id)
+      text += ` as `
+      text += templates.project(notif.title, notif.project_id)
+    } else if (notif.type === 'followuser') {
+      text += templates.user(notif.actor_username)
+      text += ` followed you`
+    } else if (notif.type === 'forumpost') {
+      text += `There are new posts on `
+      text += templates.forumThread(notif.topic_title, notif.topic_id)
+    } else if (notif.type === 'curatorinvite') {
+      text += templates.user(notif.actor_username)
+      text += ` invited you to curate the studio `
+      text += templates.studio(notif.title, notif.gallery_id)
+    } else if (notif.type === 'becomeownerstudio') {
+      text += templates.user(notif.actor_username)
+      text += ` promoted you to a manager of the studio `
+      console.log(notif)
+      text += templates.studio(notif.gallery_title, notif.gallery_id)
+    } else if (notif.type === 'studioactivity') {
+      text += `There is new activity in `
+      text += templates.studio(notif.title, notif.gallery_id)
+    } else {
+      text += `I'm not sure what kind of notification this is (${notif.type})`
+    }
+
+    const lastChar = text.endsWith('</a>') ? text[text.length - 5] : last(text)
+    if (lastChar !== '.') {
+      text += '.'
+    }
+
+    text += '</li>'
+    return text
+  }
+}
+
+const parseCookies = function(request) {
+  const list = {}
+  const rc = request.headers.cookie
+
+  if (rc) {
+    for (let cookie of rc.split(';')) {
+      const [ name, ...rest ] = cookie.split('=')
+      list[name.trim()] = decodeURI(rest.join('='))
+    }
+  }
+
+  return list
+}
+
+const getData = function(request) {
+  return new Promise((resolve, reject) => {
+    let body = ''
+
+    request.on('data', data => {
+      body += data
+
+      if (body.length > 1e6) {
+        request.connection.destroy()
+        reject('Too much data')
+      }
+    })
+
+    request.on('end', () => {
+      resolve(qs.parse(body))
+    })
+  })
+}
+
+const handleRequest = async (request, response) => {
+  const { pathname, query } = url.parse(request.url)
+  const queryData = qs.parse(query)
+  const cookie = parseCookies(request)
+
+  if (request.url === '/login') {
+    await page(request, response, fixWS`
+      <h1>Login</h1>
+      <form action="/post-login" method="post">
+        <p><label>Username: <input name="username"></label></p>
+        <p><label>Token: <input type="password" name="token"></label></p>
+        <p><label>Session ID: <input type="password" name="sessionid"></label></p>
+        <p><label>CSRF Token: <input type="password" name="csrftoken"></label></p>
+        <p><button>Login</button></p>
+      </form>
+    `)
+
+    return
+  }
+
+  if (request.url === '/post-login') {
+    const { username, token, sessionid, csrftoken } = await getData(request)
+
+    response.setHeader('Set-Cookie', [
+      `username=${username}; HttpOnly`,
+      `token=${token}; HttpOnly`,
+      `sessionid=${sessionid}; HttpOnly`,
+      `csrftoken=${csrftoken}; HttpOnly`
+    ])
+
+    request.headers.cookie = response.getHeader('Set-Cookie').join(';') // Cheating! To make the header show right.
+
+    await page(request, response, fixWS`
+      <p>Okay, you're logged in.</p>
+    `)
+
+    return
+  }
+
+  if (request.url === '/logout') {
+    await page(request, response, fixWS`
+      <h1>Log Out</h1>
+      <form action="/post-logout" method="post">
+        <p>Are you sure you want to log out?</p>
+        <p><button>Log out</button></p>
+      </form>
+    `)
+
+    return
+  }
+
+  if (request.url === '/post-logout') {
+    const { username, token, sessionid, csrftoken } = await getData(request)
+
+    const clearCookie = name => `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`
+
+    request.headers.cookie = '' // Cheating, kind of, but to make the header print right :)
+    response.setHeader('Set-Cookie', ['username', 'token', 'sessionid', 'csrftoken'].map(clearCookie))
+
+    await page(request, response, fixWS`
+      <p>Okay, you've been logged out.</p>
+    `)
+
+    return
+  }
+
+  if (pathname === '/notifications') {
+    if (!cookie.username) {
+      await page(request, response, fixWS`
+        <p>Sorry, you can't view your notifications until you've <a href="/login">logged in</a>.</p>
+      `)
+
+      return
+    }
+
+    let { page: pageNumber = 1 } = queryData
+    pageNumber = parseInt(pageNumber)
+    if (isNaN(pageNumber)) {
+      pageNumber = 1
+    } else {
+      pageNumber = Math.max(1, pageNumber)
+    }
+
+    const notifications = await getNotifications(cookie.username, cookie.token, pageNumber)
+    const nText = arr => arr.map(n => templates.notification(n, cookie.username)).join('\n')
+
+    const notificationCount = await getMessageCount(cookie.username)
+    const currentPageNewCount = Math.max(notificationCount - (limit * (pageNumber - 1)), 0)
+    let listText = ''
+
+    if (currentPageNewCount > 0) {
+      listText += fixWS`
+        <h2>New notifications:</h2>
+        <ul>
+          ${nText(notifications.slice(0, currentPageNewCount))}
+        </ul>
+        <form action="/mark-notifications-as-read" method="post">
+          <p><button>Mark as read</button></p>
+        </form>
+      `
+    }
+
+    listText += `
+      <h2>Read notifications:</h2>
+      <ul>
+        ${nText(notifications.slice(currentPageNewCount))}
+      </ul>
+    `
+
+    await page(request, response, fixWS`
+      <h1>Notifications</h1>
+      ${listText}
+      <p>You are on page ${pageNumber}.
+        ${notifications.length === limit && `<a href="/notifications?page=${pageNumber + 1}">Next</a>`}
+        ${pageNumber > 1 && `<a href="/notifications?page=${pageNumber - 1}">Previous</a>`}</p>
+    `)
+
+    return
+  }
+
+  if (pathname === '/mark-notifications-as-read') {
+    await markNotificationsAsRead(cookie.sessionid, cookie.csrftoken)
+
+    response.writeHead(302, {
+      'Location': '/notifications'
+    })
+
+    response.end()
+
+    return
+  }
+
+  const projectMatch = pathname.match(/^\/projects\/([0-9]*)\/?/)
+  if (projectMatch) {
+    const projectID = projectMatch[1]
+
+    const project = await getProject(projectID, cookie.token)
+    if (project.code === 'NotFound') {
+      await page(request, response, fixWS`
+        Sorry, that project either doesn't exist or isn't shared.
+      `)
+      return
+    }
+
+    await page(request, response, fixWS`
+      <h1>${project.title}</h1>
+      <p><img src="${project.image}" alt="The thumbnail for this project"></p>
+      <p><a href="https://projects.scratch.mit.edu/${project.id}">Download!</a></p>
+      ${project.instructions ? fixWS`
+        <h2>Instructions</h2>
+        ${templates.longField(project.instructions)}
+      ` : fixWS`
+        <p>(No instructions.)</p>
+      `}
+      ${project.description ? fixWS`
+        <h2>Notes and Credits</h2>
+        ${templates.longField(project.description)}
+      ` : fixWS`
+        <p>(No notes and credits.)</p>
+      `}
+    `)
+
+    return
+  }
+
+  const userMatch = pathname.match(/^\/users\/([a-zA-Z0-9\-_]*)\/?/)
+  if (userMatch) {
+    const username = userMatch[1]
+
+    const user = await getUser(username)
+    if (username.code === 'NotFound') {
+      await page(request, response, fixWS`
+        Sorry, that user doesn't exist.
+      `)
+      return
+    }
+
+    await page(request, response, fixWS`
+      <h1><img src="${user.profile.images['60x60']}" height="60px" alt="This user's profile picture"> ${user.username}</h1>
+      ${user.profile.bio ? fixWS`
+        <h2>About Me</h2>
+        ${templates.longField(user.profile.bio)}
+      ` : fixWS`
+        <p>(No "about me".)</p>
+      `}
+      ${user.profile.status ? fixWS`
+        <h2>What I'm Working On</h2>
+        ${templates.longField(user.profile.status)}
+      ` : fixWS`
+        <p>(No "what I'm working on".)</p>
+      `}
+    `)
+
+    return
+  }
+
+  if (pathname === '/style.css') {
+    response.writeHead(200, {
+      'Content-Type': 'text/css'
+    })
+
+    response.end(await readFile('style.css'))
+
+    return
+  }
+
+  if (pathname === '/') {
+    await page(request, response, fixWS`
+      You are at the homepage. Sorry, I haven't implmented any content for it yet.
+    `)
+  }
+
+  await page(request, response, fixWS`
+    404. Sorry, I'm not sure where you are right now.
+  `)
+}
+
+const server = http.createServer((request, response) => {
+  handleRequest(request, response).catch(error => {
+    console.error(error)
+    return page(request, response, fixWS`
+      500. Sorry, there was an internal server error.
+    `)
+  })
+})
+
+server.listen(8000)
+console.log('Go!')