diff options
Diffstat (limited to 'index.js')
-rwxr-xr-x | index.js | 443 |
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, '<').replace(/>/g, '>') +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!') |