├── .gitignore ├── README.md ├── backend ├── .env.example ├── WebSockets.js ├── ecosystem.config.cjs ├── index.js ├── package-lock.json ├── package.json └── utils │ ├── _leetspeakMap.json │ ├── discord-webhook.js │ ├── fileStorage.js │ ├── profanity-filter.js │ ├── recentUsers.js │ ├── removeOldProjects.js │ ├── scratch-auth.js │ ├── sessionManager.js │ └── userManager.js ├── eslint.config.js └── extension ├── UI_modal ├── index.js └── style.css ├── background.js ├── background ├── auth.js ├── livescratchProject.js ├── socket.io.js └── socket.io.js.map ├── css └── editor.css ├── icon128.png ├── img ├── LogoLiveScratch.svg ├── LogoLiveScratchFlat.svg └── icons │ ├── dark-switch.svg │ └── light-switch.svg ├── injectors ├── all.js ├── editor.js └── mystuff.js ├── jsconfig.json ├── manifest.json ├── popups ├── popup.css ├── popup.html └── script.js ├── projects ├── dark-colors.css ├── index.html ├── light-colors.css ├── script.js ├── style.css └── theme.js ├── scripts ├── badge.css ├── badge.js ├── editor.js ├── mystuff.js └── verify.js └── sounds └── ping.mp3 /.gitignore: -------------------------------------------------------------------------------- 1 | */node_modules/ 2 | *.crx 3 | *.pem 4 | *.DS_Store 5 | backend/storage 6 | /debug/ 7 | backend/.env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WIP -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT= # Port to run the backend on 2 | CHAT_WEBHOOK_URL= # discord webhook url 3 | ADMIN_USER={"":""} # Custom ones, Doesnt have to be scratch username and password 4 | AUTH_PROJECTS=["728098174"] 5 | ADMIN=[] # List of admin users, Has to be scratch usernames -------------------------------------------------------------------------------- /backend/WebSockets.js: -------------------------------------------------------------------------------- 1 | import { Filter } from './utils/profanity-filter.js'; 2 | import { postText } from './utils/discord-webhook.js'; 3 | import { addRecent} from './utils/recentUsers.js'; 4 | import {fullAuthenticate} from './utils/scratch-auth.js'; 5 | import * as fileStorageUtils from './utils/fileStorage.js'; 6 | const admin = JSON.parse(process.env.ADMIN); 7 | 8 | export default class initSockets { 9 | constructor(ioHttp, sessionManager, userManager) { 10 | this.filter = new Filter(); 11 | 12 | this.sessionManager = sessionManager; 13 | this.userManager = userManager; 14 | 15 | this.messageHandlers = { 16 | 'joinSession':(data,client)=>{ 17 | if(!fullAuthenticate(data.username,data.token,data.id)) {client.send({noauth:true}); return;} 18 | 19 | this.sessionManager.join(client,data.id,data.username); 20 | if(data.pk) { this.userManager.getUser(data.username).pk = data.pk; } 21 | },'joinSessions':(data,client)=>{ 22 | if(!fullAuthenticate(data.username,data.token,data.id)) {client.send({noauth:true}); return;} 23 | 24 | data.ids.forEach(id=>{this.sessionManager.join(client,id,data.username);}); 25 | if(data.pk) { this.userManager.getUser(data.username).pk = data.pk; } 26 | }, 27 | 'leaveSession':(data,client)=>{ 28 | this.sessionManager.leave(client,data.id); 29 | }, 30 | 'projectChange':(data,client,callback)=>{ 31 | if(!fullAuthenticate(data.username,data.token,data.blId)) {client.send({noauth:true}); return;} 32 | 33 | this.sessionManager.projectChange(data.blId,data,client); 34 | callback(this.sessionManager.getVersion(data.blId)); 35 | }, 36 | 'setTitle':(data,client)=>{ 37 | if(!fullAuthenticate(data.username,data.token,data.blId)) {client.send({noauth:true}); return;} 38 | 39 | let project = this.sessionManager.getProject(data.blId); 40 | if(!project) {return;} 41 | project.project.title = data.msg.title; 42 | project.session.sendChangeFrom(client,data.msg,true); 43 | }, 44 | 'setCursor':(data,client)=>{ // doesnt need to be authenticated because relies on pre-authenticated join action 45 | let project = this.sessionManager.getProject(data.blId); 46 | if(!project) {return;} 47 | let cursor = project.session.getClientFromSocket(client)?.cursor; 48 | if(!cursor) {return;} 49 | Object.entries(data.cursor).forEach(e=>{ 50 | if(e[0] in cursor) { cursor[e[0]] = e[1]; } 51 | }); 52 | }, 53 | 'chat': async (data,client)=>{ 54 | const BROADCAST_KEYWORD = 'toall '; 55 | 56 | delete data.msg.msg.linkify; 57 | let text = String(data.msg.msg.text); 58 | let sender = data.msg.msg.sender; 59 | let project = this.sessionManager.getProject(data.blId); 60 | 61 | if(!fullAuthenticate(sender,data.token,data.blId,true)) {client.send({noauth:true}); return;} 62 | if(admin.includes(sender?.toLowerCase()) && text.startsWith(BROADCAST_KEYWORD)) { 63 | let broadcast=text.slice(BROADCAST_KEYWORD.length); 64 | console.log(`broadcasting message to all users: "${broadcast}" [${sender}]`); 65 | postText(`broadcasting message to all users: "${broadcast}" [${sender}]`); 66 | this.sessionManager.broadcastMessageToAllActiveProjects(`${broadcast}`); 67 | } 68 | 69 | const isVulgar = await this.filter.isVulgar(text); 70 | if(isVulgar) { 71 | let sentTo = project.session.getConnectedUsernames().filter(uname=>uname!=sender?.toLowerCase()); 72 | let loggingMsg = '🔴 FILTERED CHAT: ' + '"' + text + '" [' + sender + '->' + sentTo.join(',') + ' | scratchid: ' + project.scratchId + ']'; 73 | 74 | text = await this.filter.getCensored(text); 75 | data.msg.msg.text = text; 76 | 77 | loggingMsg = loggingMsg + `\nCensored as: "${text}"`; 78 | console.error(loggingMsg); 79 | postText(loggingMsg); 80 | } 81 | 82 | let banned = await fileStorageUtils.getBanned(false); 83 | if(banned?.includes?.(sender)) {return;} 84 | 85 | project?.onChat(data.msg,client); 86 | // logging 87 | let sentTo = project.session.getConnectedUsernames().filter(uname=>uname!=sender?.toLowerCase()); 88 | let loggingMsg = '"' + text + '" [' + sender + '->' + sentTo.join(',') + ' | scratchid: ' + project.scratchId + ']'; 89 | console.log(loggingMsg); 90 | postText(loggingMsg); 91 | }, 92 | }; 93 | 94 | this.ioHttp = ioHttp; 95 | this.ioHttp.on('connection', this.onSocketConnection.bind(this)); 96 | } 97 | 98 | onSocketConnection(client) { 99 | client.on('message',(data,callback)=>{ 100 | if(data.type in this.messageHandlers) { 101 | 102 | // record analytic first to stop reloading after project leave 103 | analytic: try{ 104 | let id = data.blId ?? data.id ?? null; 105 | if (!id) { break analytic; } 106 | let project = this.sessionManager.getProject(id); 107 | if (!project) { break analytic; } 108 | let connected = project.session?.getConnectedUsernames(); 109 | connected?.forEach?.(username => { 110 | addRecent(username, connected.length>1, project.sharedWith.length); 111 | }); 112 | } catch (e) { console.error('error with analytic message tally'); console.error(e); } 113 | 114 | try{this.messageHandlers[data.type](data,client,callback);} 115 | catch(e){console.error('error during messageHandler',e);} 116 | } else { console.log('discarded unknown mesage type: ' + data.type); } 117 | }); 118 | 119 | client.on('disconnect',(reason)=>{ 120 | this.sessionManager.disconnectSocket(client); 121 | }); 122 | } 123 | } -------------------------------------------------------------------------------- /backend/ecosystem.config.cjs: -------------------------------------------------------------------------------- 1 | const result = require('dotenv').config(); 2 | if (result.error) { 3 | console.error('No .env file found, Create one using .env.example'); 4 | process.exit(1); 5 | } 6 | 7 | module.exports = { 8 | apps: [ 9 | { 10 | name: "LiveScratch", 11 | script: "./index.js", 12 | killTimeout: 60000, 13 | env: { 14 | PORT: process.env.PORT, 15 | CHAT_WEBHOOK_URL: process.env.CHAT_WEBHOOK_URL, 16 | ADMIN_USER: process.env.ADMIN_USER, 17 | AUTH_PROJECTS: process.env.AUTH_PROJECTS, 18 | ADMIN: process.env.ADMIN, 19 | } 20 | } 21 | ] 22 | }; -------------------------------------------------------------------------------- /backend/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | // be mindful of: 4 | // numbers being passed as strings 5 | 6 | /////////// 7 | import express from 'express'; 8 | const app = express(); 9 | import cors from 'cors'; 10 | app.use(cors({origin:'*'})); 11 | app.use(express.json({ limit: '5MB' })); 12 | import basicAuth from 'express-basic-auth'; 13 | import http from 'http'; 14 | 15 | let httpServer = http.createServer(app); 16 | 17 | import {Server} from 'socket.io'; 18 | const ioHttp = new Server(httpServer, { 19 | cors:{origin:'*'}, 20 | maxHttpBufferSize:2e7, 21 | }); 22 | 23 | import SessionManager from './utils/sessionManager.js'; 24 | import UserManager from './utils/userManager.js'; 25 | import sanitize from 'sanitize-filename'; 26 | 27 | export let isFinalSaving = false; 28 | 29 | import * as fileStorageUtils from './utils/fileStorage.js'; 30 | import { installCleaningJob } from './utils/removeOldProjects.js'; 31 | import { countRecentShared, recordPopup } from './utils/recentUsers.js'; 32 | import {setPaths, authenticate, fullAuthenticate, freePassesPath, freePasses} from './utils/scratch-auth.js'; 33 | import initSockets from './WebSockets.js'; 34 | 35 | const restartMessage = 'The Livescratch server is restarting. You will lose connection for a few seconds.'; 36 | 37 | function sleep(millis) { 38 | return new Promise(res=>setTimeout(res,millis)); 39 | } 40 | 41 | // Load session and user manager objects 42 | 43 | 44 | /// LOAD SESSION MANAGER 45 | let sessionsObj = fileStorageUtils.loadMapFromFolderRecursive('storage'); 46 | 47 | var sessionManager = SessionManager.fromJSON(sessionsObj); 48 | 49 | /// LOAD USER MANAGER 50 | var userManager = new UserManager(); 51 | setPaths(app,userManager,sessionManager); 52 | 53 | // let id = sessionManager.newProject('tester124','644532638').id 54 | // sessionManager.linkProject(id,'602888445','ilhp10',5) 55 | // userManager.befriend('ilhp10','tester124') 56 | // userManager.befriend('tester124','ilhp10') 57 | // console.log(JSON.stringify(sessionManager)) 58 | 59 | fileStorageUtils.saveMapToFolder(sessionManager.livescratch,fileStorageUtils.livescratchPath); 60 | 61 | fileStorageUtils.saveLoop(sessionManager); 62 | 63 | async function finalSave(sessionManager) { 64 | try{ 65 | if(isFinalSaving) {return;} // Exit early if another save is in progress to avoid duplication 66 | console.log('sending message "' + restartMessage + '"'); 67 | sessionManager.broadcastMessageToAllActiveProjects(restartMessage); 68 | await sleep(1000 * 2); 69 | isFinalSaving = true; 70 | console.log('final saving...'); 71 | fs.writeFileSync(fileStorageUtils.lastIdPath,(sessionManager.lastId).toString()); 72 | fs.writeFileSync(freePassesPath,JSON.stringify(freePasses)); 73 | await sessionManager.finalSaveAllProjects(); // Save all active project data to disk. This operation also automatically "offloads" them (frees memory). 74 | saveMapToFolder(userManager.users,fileStorageUtils.usersPath); 75 | await saveRecent(); 76 | process.exit(); 77 | } catch (e) { 78 | await sleep(1000 * 10); // If an error occurs, wait 10 seconds before allowing another save attempt 79 | isFinalSaving = false; 80 | } 81 | } 82 | 83 | setTimeout(()=>installCleaningJob(sessionManager,userManager),1000 * 10); 84 | 85 | new initSockets(ioHttp, sessionManager, userManager); 86 | // todo: save info & credits here 87 | app.post('/newProject/:scratchId/:owner',(req,res)=>{ 88 | if(!authenticate(req.params.owner,req.headers.authorization)) {res.send({noauth:true}); return;} 89 | if( !req.params.scratchId || ( sanitize(req.params.scratchId.toString()) == '' ) ) {res.send({err:'invalid scratch id'}); return;} 90 | let project = sessionManager.getScratchToLSProject(req.params.scratchId); 91 | let json = req.body; 92 | if(!project) { 93 | console.log('creating new project from scratch project: ' + req.params.scratchId + ' by ' + req.params.owner + ' titled: ' + req.query.title); 94 | project = sessionManager.newProject(req.params.owner,req.params.scratchId,json,req.query.title); 95 | userManager.newProject(req.params.owner,project.id); 96 | } 97 | res.send({id:project.id}); 98 | }); 99 | 100 | app.get('/lsId/:scratchId/:uname',(req,res)=>{ 101 | let lsId = sessionManager.getScratchProjectEntry(req.params.scratchId)?.blId; 102 | if(!lsId) {res.send(lsId); return;} 103 | let project = sessionManager.getProject(lsId); 104 | if(!project) { // if the project doesnt exist, dont send it!!! 105 | sessionManager.deleteScratchProjectEntry(req.params.scratchId); 106 | res.send(null); 107 | return; 108 | } 109 | let hasAccess = fullAuthenticate(req.params.uname,req.headers.authorization,lsId); 110 | // let hasAccess = project.isSharedWithCaseless(req.params.uname) 111 | 112 | res.send(hasAccess ? lsId : null); 113 | }); 114 | app.get('/scratchIdInfo/:scratchId',(req,res)=>{ 115 | if (sessionManager.doesScratchProjectEntryExist(req.params.scratchId)) { 116 | res.send(sessionManager.getScratchProjectEntry(req.params.scratchId)); 117 | } else { 118 | res.send({err:('could not find livescratch project associated with scratch project id: ' + req.params.scratchId)}); 119 | } 120 | }); 121 | // meechapooch: "todo: sync info and credits with this endpoint as well?" Waakul: Na hail naw, setting idea unlocked! 122 | app.get('/projectTitle/:id',(req,res)=>{ 123 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} 124 | 125 | let project = sessionManager.getProject(req.params.id); 126 | if(!project) { 127 | res.send({err:'could not find project with livescratch id: ' + req.params.id}); 128 | } else { 129 | res.send({title:project.project.title}); 130 | } 131 | }); 132 | app.post('/projectSavedJSON/:lsId/:version',(req,res)=>{ 133 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.lsId)) {res.send({noauth:true}); return;} 134 | 135 | let json = req.body; 136 | let project = sessionManager.getProject(req.params.lsId); 137 | if(!project) { 138 | console.log('Could not find project: '+req.params.lsId); 139 | res.send({ err: 'Couldn\'t find the specified project!' }); 140 | return; 141 | } 142 | project.scratchSavedJSON(json,parseFloat(req.params.version)); 143 | res.send({ success: 'Successfully saved the project!' }); 144 | }); 145 | app.get('/projectJSON/:lsId',(req,res)=>{ 146 | if(!fullAuthenticate(req.query.username,req.headers.authorization,req.params.lsId)) {res.send({noauth:true}); return;} 147 | 148 | let lsId = req.params.lsId; 149 | let project = sessionManager.getProject(lsId); 150 | if(!project) {res.sendStatus(404); return;} 151 | let json = project.projectJson; 152 | let version = project.jsonVersion; 153 | res.send({json,version}); 154 | return; 155 | }); 156 | 157 | app.use('/html',express.static('static')); 158 | app.get('/changesSince/:id/:version',(req,res)=>{ 159 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} 160 | 161 | let project = sessionManager.getProject(req.params.id); 162 | if(!project) {res.send([]);} 163 | else { 164 | 165 | let oldestChange = project.project.getIndexZeroVersion(); 166 | let clientVersion = req.params.version; 167 | let jsonVersion = project.jsonVersion; 168 | let forceReload = clientVersion=oldestChange-1; 169 | if(clientVersion{ 189 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} 190 | let project = sessionManager.getProject(req.params.id); 191 | if(!project) {res.send([]);} 192 | else { 193 | res.send(project.getChat()); 194 | } 195 | }); 196 | 197 | app.use('/ban', basicAuth({ 198 | users: JSON.parse(process.env.ADMIN_USER), 199 | challenge: true, 200 | })); 201 | app.put('/ban/:username', (req,res) => { 202 | fileStorageUtils.ban(req.params.username) 203 | .then(() => { 204 | res.send({ success: 'Successfully banned!' }); 205 | }) 206 | .catch((err) => { 207 | res.send({ err: err }); 208 | }); 209 | }); 210 | 211 | app.use('/unban', basicAuth({ 212 | users: JSON.parse(process.env.ADMIN_USER), 213 | challenge: true, 214 | })); 215 | app.put('/unban/:username', (req,res) => { 216 | fileStorageUtils.unban(req.params.username) 217 | .then(() => { 218 | res.send({ success: 'Successfully unbanned!' }); 219 | }) 220 | .catch((err) => { 221 | res.send({ err: err }); 222 | }); 223 | }); 224 | 225 | app.use('/banned', basicAuth({ 226 | users: JSON.parse(process.env.ADMIN_USER), 227 | challenge: true, 228 | })); 229 | app.get('/banned', (req,res) => { 230 | fileStorageUtils.getBanned() 231 | .then((bannedList) => { 232 | res.send(bannedList); 233 | }) 234 | .catch((err) => { 235 | res.send({ err: err }); 236 | }); 237 | }); 238 | 239 | let cachedStats = null; 240 | let cachedStatsTime = 0; 241 | let cachedStatsLifetimeMillis = 1000; 242 | app.use('/stats',basicAuth({ 243 | users: JSON.parse(process.env.ADMIN_USER), 244 | challenge: true, 245 | })); 246 | app.get('/stats',(req,res)=>{ 247 | if(Date.now() - cachedStatsTime > cachedStatsLifetimeMillis) { 248 | cachedStats = sessionManager.getStats(); 249 | cachedStats.cachedAt = new Date(); 250 | cachedStatsTime = Date.now(); 251 | } 252 | res.send(cachedStats); 253 | }); 254 | 255 | app.get('/dau/:days',(req,res)=>{ 256 | res.send(String(countRecentShared(parseFloat(req.params.days)))); 257 | }); 258 | app.put('/linkScratch/:scratchId/:lsId/:owner',(req,res)=>{ 259 | if(!fullAuthenticate(req.params.owner,req.headers.authorization,req.params.lsId)) {res.send({noauth:true}); return;} 260 | 261 | console.log('linking:',req.params); 262 | sessionManager.linkProject(req.params.lsId,req.params.scratchId,req.params.owner,0); 263 | res.send({ success: 'Successfully linked!' }); 264 | }); 265 | app.get('/userExists/:username',(req,res)=>{ 266 | res.send(userManager.userExists(req.params.username) && !userManager.getUser(req.params.username).privateMe); 267 | }); 268 | app.put('/privateMe/:username/:private',(req,res)=>{ 269 | req.params.username = sanitize(req.params.username); 270 | if(!authenticate(req.params.username,req.headers.authorization)) {res.send({noauth:true}); return;} 271 | let user = userManager.getUser(req.params.username); 272 | user.privateMe = req.params.private == 'true'; 273 | res.status(200).end(); 274 | }); 275 | app.get('/privateMe/:username',(req,res)=>{ 276 | req.params.username = sanitize(req.params.username); 277 | if(!authenticate(req.params.username,req.headers.authorization)) {res.send({noauth:true}); return;} 278 | let user = userManager.getUser(req.params.username); 279 | res.send(user.privateMe); 280 | }); 281 | app.get('/userRedirect/:scratchId/:username',(req,res)=>{ 282 | 283 | let project = sessionManager.getScratchToLSProject(req.params.scratchId); 284 | 285 | if(!fullAuthenticate(req.params.username,req.headers.authorization,project?.id)) {res.send({noauth:true,goto:'none'}); return;} 286 | 287 | if(!project) {res.send({goto:'none'}); return;} 288 | 289 | let ownedProject = project.getOwnersProject(req.params.username); 290 | if(!!ownedProject) { 291 | res.send({goto:ownedProject.scratchId}); 292 | } else { 293 | res.send({goto:'new', lsId:project.id}); 294 | } 295 | }); 296 | 297 | app.get('/active/:lsId',(req,res)=>{ 298 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.lsId)) {res.send({noauth:true}); return;} 299 | 300 | let usernames = sessionManager.getProject(req.params.lsId)?.session.getConnectedUsernames(); 301 | let clients = sessionManager.getProject(req.params.lsId)?.session.getConnectedUsersClients(); 302 | if(usernames) { 303 | res.send(usernames.map(name=>{ 304 | let user = userManager.getUser(name); 305 | return {username:user.username,pk:user.pk,cursor:clients[name].cursor}; 306 | })); 307 | } else { 308 | res.send({err:'could not get users for project with id: ' + req.params.lsId}); 309 | } 310 | }); 311 | 312 | app.get('/',(req,res)=>{ 313 | res.send('LiveScratch API'); 314 | }); 315 | 316 | app.post('/friends/:user/:friend',(req,res)=>{ 317 | if(!authenticate(req.params.user,req.headers.authorization)) {res.send({noauth:true}); return;} 318 | 319 | if (!userManager.userExists(req.params.friend)) { 320 | res.sendStatus(404); 321 | return; 322 | } 323 | 324 | userManager.befriend(req.params.user,req.params.friend); 325 | res.send({ success: 'Successfully friended!' }); 326 | }); 327 | app.delete('/friends/:user/:friend',(req,res)=>{ 328 | if(!authenticate(req.params.user,req.headers.authorization)) {res.send({noauth:true}); return;} 329 | 330 | userManager.unbefriend(req.params.user,req.params.friend); 331 | res.send({ success: 'Succesfully unfriended!' }); 332 | 333 | }); 334 | app.get('/friends/:user',(req,res)=>{ 335 | recordPopup(req.params.user); 336 | if(!authenticate(req.params.user,req.headers.authorization)) {res.send({noauth:true}); return;} 337 | 338 | res.send(userManager.getUser(req.params.user)?.friends); 339 | }); 340 | 341 | // get list of livescratch id's shared TO user (from another user) 342 | app.get('/userProjects/:user',(req,res)=>{ 343 | if(!authenticate(req.params.user,req.headers.authorization)) {res.send({noauth:true}); return;} 344 | 345 | res.send(userManager.getShared(req.params.user)); 346 | }); 347 | // get list of scratch project info shared with user for displaying in mystuff 348 | app.get('/userProjectsScratch/:user',(req,res)=>{ 349 | if(!authenticate(req.params.user,req.headers.authorization)) {res.send({noauth:true}); return;} 350 | 351 | let livescratchIds = userManager.getAllProjects(req.params.user); 352 | let projectsList = livescratchIds.map(id=>{ 353 | let projectObj = {}; 354 | let project = sessionManager.getProject(id); 355 | if(!project) {return null;} 356 | projectObj.scratchId = project.getOwnersProject(req.params.user)?.scratchId; 357 | if(!projectObj.scratchId) {projectObj.scratchId = project.scratchId;} 358 | projectObj.blId = project.id; 359 | projectObj.title = project.project.title; 360 | projectObj.lastTime = project.project.lastTime; 361 | projectObj.lastUser = project.project.lastUser; 362 | projectObj.online = project.session.getConnectedUsernames(); 363 | 364 | return projectObj; 365 | }).filter(Boolean); // filter out non-existant projects // TODO: automatically delete dead pointers like this 366 | res.send(projectsList); 367 | }); 368 | 369 | app.put('/leaveScratchId/:scratchId/:username',(req,res)=>{ 370 | let project = sessionManager.getScratchToLSProject(req.params.scratchId); 371 | 372 | if(!fullAuthenticate(req.params.username, req.headers.authorization, project, false)) {res.send({noauth:true}); return;} 373 | userManager.unShare(req.params.username, project.id); 374 | sessionManager.unshareProject(project.id, req.params.username); 375 | res.send({ success: 'User succesfully removed!'}); 376 | }); 377 | app.put('/leaveLSId/:lsId/:username',(req,res)=>{ 378 | if(!authenticate(req.params.username,req.headers.authorization)) {res.send({noauth:true}); return;} 379 | userManager.unShare(req.params.username, req.params.lsId); 380 | sessionManager.unshareProject(req.params.lsId, req.params.username); 381 | res.send({ success: 'User succesfully removed!'}); 382 | }); 383 | app.get('/verify/test',(req,res)=>{ 384 | res.send({verified:authenticate(req.query.username,req.headers.authorization)}); 385 | }); 386 | 387 | 388 | app.get('/share/:id',(req,res)=>{ 389 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} // todo fix in extension 390 | 391 | let project = sessionManager.getProject(req.params.id); 392 | let list = project?.sharedWith; 393 | if(!list) {res.send({ err: 'No shared list found for the specified project.' }); return;} 394 | list = list.map(name=>({username:name,pk:userManager.getUser(name).pk})); // Add user ids for profile pics 395 | res.send(list ? [{username:project.owner,pk:userManager.getUser(project.owner).pk}].concat(list) : {err:'could not find livescratch project: ' + req.params.id} ); 396 | }); 397 | app.put('/share/:id/:to/:from',(req,res)=>{ 398 | if(!fullAuthenticate(req.params.from,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} 399 | 400 | if(sessionManager.getProject(req.params.id)?.owner == req.params.to) { 401 | res.send({ err: 'Cannot share the project with the owner.' }); 402 | return; 403 | } 404 | 405 | if (!userManager.userExists(req.params.to)) { 406 | res.sendStatus(404); 407 | return; 408 | } 409 | 410 | sessionManager.shareProject(req.params.id, req.params.to, req.query.pk); 411 | userManager.getUser(req.params.to).pk = req.query.pk; 412 | userManager.share(req.params.to, req.params.id, req.params.from); 413 | res.send({ success: 'Project successfully shared.' }); 414 | }); 415 | app.put('/unshare/:id/:to/',(req,res)=>{ 416 | if(!fullAuthenticate(req.headers.uname,req.headers.authorization,req.params.id)) {res.send({noauth:true}); return;} 417 | 418 | if(sessionManager.getProject(req.params.id)?.owner == req.params.to) { 419 | res.send({ err: 'Cannot unshare the project with the owner.' }); 420 | return; 421 | } 422 | sessionManager.unshareProject(req.params.id, req.params.to); 423 | userManager.unShare(req.params.to, req.params.id); 424 | res.send({ success: 'Project successfully unshared.' }); 425 | }); 426 | 427 | const port = process.env.PORT; 428 | httpServer.listen(port,'0.0.0.0'); 429 | console.log('listening http on port ' + port); 430 | 431 | 432 | // initial handshake: 433 | // client says hi, sends username & creds, sends project id 434 | // server generates id, sends id 435 | // server sends JSON or scratchId 436 | // client loads, sends when isReady 437 | // connection success!! commense the chitter chatter! 438 | 439 | 440 | 441 | 442 | 443 | 444 | // copied from https://stackoverflow.com/questions/14031763/doing-a-cleanup-action-just-before-node-js-exits 445 | 446 | process.stdin.resume();//so the program will not close instantly 447 | 448 | async function exitHandler(options, exitCode) { 449 | if (options.cleanup) console.log('clean'); 450 | if (exitCode || exitCode === 0) console.log(exitCode); 451 | 452 | if(options.exit) {finalSave(sessionManager);} 453 | 454 | } 455 | 456 | //do something when app is closing 457 | process.on('exit', exitHandler.bind(null,{cleanup:true})); 458 | 459 | //catches ctrl+c event 460 | process.on('SIGINT', exitHandler.bind(null, {exit:true})); 461 | 462 | // catches "kill pid" (for example: nodemon restart) 463 | process.on('SIGUSR1', exitHandler.bind(null, {exit:true})); 464 | process.on('SIGUSR2', exitHandler.bind(null, {exit:true})); 465 | 466 | //catches uncaught exceptions 467 | process.on('uncaughtException', exitHandler.bind(null, {exit:true})); -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livescratch-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "pm2 start ./ecosystem.config.cjs", 9 | "stop": "pm2 stop LiveScratch", 10 | "delete": "pm2 delete LiveScratch" 11 | }, 12 | "dependencies": { 13 | "@coffeeandfun/google-profanity-words": "^2.1.0", 14 | "@types/express": "^5.0.0", 15 | "body-parser": "^1.19.2", 16 | "clone": "^2.1.2", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.4.5", 19 | "express": "^4.17.2", 20 | "express-basic-auth": "^1.2.1", 21 | "http": "^0.0.1-security", 22 | "n-readlines": "^1.0.1", 23 | "node-cron": "^3.0.3", 24 | "node-fetch": "^3.2.10", 25 | "pm2": "^5.4.3", 26 | "sanitize-filename": "^1.6.3", 27 | "socket.io": "^4.4.1", 28 | "socket.io-client": "^4.4.1" 29 | }, 30 | "devDependencies": { 31 | "eslint": "^9.17.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/utils/_leetspeakMap.json: -------------------------------------------------------------------------------- 1 | { 2 | "@": "a", 3 | "$": "s", 4 | "3": "e", 5 | "1": "i", 6 | "0": "o", 7 | "7": "t", 8 | "!": "i", 9 | "5": "s", 10 | "phuck": "fuck" 11 | } -------------------------------------------------------------------------------- /backend/utils/discord-webhook.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | const chatWebhookUrl = process.env.CHAT_WEBHOOK_URL; 3 | 4 | export function postText(text) {// node.js versions pre-v0.18.0 do not support the fetch api and require a polyfill 5 | // const fetch = require('node-fetch'); 6 | fetch( 7 | chatWebhookUrl, 8 | { 9 | method: 'post', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | body: JSON.stringify({ 14 | // the username to be displayed 15 | // username: 'webhook', 16 | // the avatar to be displayed 17 | // avatar_url:'https://cdn.discordapp.com/avatars/411256446638882837/9a12fc7810795ded801fdb0401db0be6.png', 18 | // contents of the message to be sent 19 | content: 20 | text, 21 | // 'user mention: <@279098137484722176>, role mention: <@&496160161459863552>, channel mention: <#508500699458306049>', 22 | // enable mentioning of individual users or roles, but not @everyone/@here 23 | // allowed_mentions: { 24 | // parse: ['users'], 25 | // // parse: ['users', 'roles'], 26 | // }, 27 | // embeds to be sent 28 | // embeds: [ 29 | // { 30 | // // decimal number colour of the side of the embed 31 | // color: 11730954, 32 | // // author 33 | // // - icon next to text at top (text is a link) 34 | // author: { 35 | // name: 'dragonwocky', 36 | // url: 'https://dragonwocky.me/', 37 | // icon_url: 'https://dragonwocky.me/assets/avatar.jpg', 38 | // }, 39 | // // embed title 40 | // // - link on 2nd row 41 | // title: 'title', 42 | // url: 43 | // 'https://gist.github.com/dragonwocky/ea61c8d21db17913a43da92efe0de634', 44 | // // thumbnail 45 | // // - small image in top right corner. 46 | // thumbnail: { 47 | // url: 48 | // 'https://cdn.discordapp.com/avatars/411256446638882837/9a12fc7810795ded801fdb0401db0be6.png', 49 | // }, 50 | // // embed description 51 | // // - text on 3rd row 52 | // description: 'description', 53 | // // custom embed fields: bold title/name, normal content/value below title 54 | // // - located below description, above image. 55 | // fields: [ 56 | // { 57 | // name: 'field 1', 58 | // value: 'value', 59 | // }, 60 | // { 61 | // name: 'field 2', 62 | // value: 'other value', 63 | // }, 64 | // ], 65 | // // image 66 | // // - picture below description(and fields) 67 | // image: { 68 | // url: 69 | // 'http://tolkiengateway.net/w/images/thumb/7/75/J.R.R._Tolkien_-_Ring_verse.jpg/300px-J.R.R._Tolkien_-_Ring_verse.jpg', 70 | // }, 71 | // // footer 72 | // // - icon next to text at bottom 73 | // footer: { 74 | // text: 'footer', 75 | // icon_url: 76 | // 'https://cdn.discordapp.com/avatars/411256446638882837/9a12fc7810795ded801fdb0401db0be6.png', 77 | // }, 78 | // }, 79 | // ], 80 | }), 81 | }, 82 | );} -------------------------------------------------------------------------------- /backend/utils/fileStorage.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import fsp from 'fs/promises'; 3 | import path from 'path'; 4 | import sanitize from 'sanitize-filename'; 5 | import clone from 'clone'; 6 | 7 | import {freePassesPath, freePasses} from './scratch-auth.js'; 8 | import { saveRecent } from './recentUsers.js'; 9 | import { isFinalSaving } from '../index.js'; 10 | 11 | export const livescratchPath = 'storage/sessions/livescratch'; 12 | export const scratchprojectsPath = 'storage/sessions/scratchprojects'; 13 | export const lastIdPath = 'storage/sessions/lastId'; 14 | export const usersPath = 'storage/users'; 15 | export const bannedPath = 'storage/banned'; 16 | 17 | function sleep(millis) { 18 | return new Promise(res=>setTimeout(res,millis)); 19 | } 20 | 21 | if(!fs.existsSync('storage')) { 22 | fs.mkdirSync('storage'); 23 | } 24 | if(!fs.existsSync('storage/sessions/scratchprojects')) { 25 | fs.mkdirSync('storage/sessions/scratchprojects',{recursive:true}); 26 | fs.mkdirSync('storage/sessions/livescratch',{recursive:true}); 27 | } 28 | 29 | const bannedList = () => { 30 | try { 31 | const data = fs.readFileSync(bannedPath, 'utf-8'); 32 | return data.split('\n').filter(line => line.trim() !== ''); 33 | } catch (err) { 34 | if (err.code === 'ENOENT') return []; 35 | throw err; 36 | } 37 | }; 38 | 39 | export function saveMapToFolder(obj, dir) { 40 | // if obj is null, return 41 | if(!obj) {console.error('tried to save null object to dir: ' + dir); return;} 42 | // make directory if it doesnt exist 43 | if (!fs.existsSync(dir)){fs.mkdirSync(dir,{recursive:true});} 44 | Object.entries(obj).forEach(entry=>{ 45 | let stringg = JSON.stringify(entry[1]); 46 | if(stringg.length >= removeChangesStringLength && entry[1]?.project?.changes) { 47 | entry[1] = clone(entry[1],true,2); 48 | entry[1].project.changes=[]; 49 | stringg = JSON.stringify(entry[1]); 50 | } //max length is 524288 51 | 52 | entry[0] = sanitize(entry[0] + ''); 53 | if(entry[0] == '' || stringg.length > maxStringWriteLength) { 54 | console.error(`skipping writing file "${entry[0]}" because its too long or noname`); 55 | return; 56 | } 57 | try{ 58 | // console.log(`writing ${entry[0]}`) 59 | fs.writeFileSync(dir+path.sep+entry[0],stringg); 60 | } catch (e) { 61 | console.error('Error when saving filename: ' + entry[0]); 62 | console.error(e); 63 | } 64 | }); 65 | } 66 | 67 | const removeChangesStringLength = 514280; 68 | const maxStringWriteLength = 51428000; //absolute max, hopefully never reached 69 | export async function saveMapToFolderAsync(obj, dir, failsafeEh, dontRemoveChanges) { 70 | // if obj is null, return 71 | if(!obj) {console.warn('tried to save null object to dir: ' + dir); return;} 72 | // make directory if it doesnt exist 73 | if (!fs.existsSync(dir)){fs.mkdirSync(dir,{recursive:true});} 74 | let promises = []; 75 | for (let entry of Object.entries(obj)) { 76 | let id = sanitize(entry[0] + ''); 77 | let contentsObject = entry[1]; 78 | let stringg = JSON.stringify(contentsObject); 79 | if(stringg.length >= removeChangesStringLength && contentsObject?.project?.changes && !dontRemoveChanges) { 80 | console.log(`removing changes to save length on projectId: ${id}`); 81 | contentsObject = clone(contentsObject,true,2); 82 | contentsObject.project.changes=[]; 83 | stringg = JSON.stringify(contentsObject); 84 | } //max length is 524288 85 | if(failsafeEh) { // to speed up the saving process because we know that the actual save will write changes, and this is quick in case the server crashes 86 | if(contentsObject?.project?.changes) { 87 | contentsObject = clone(contentsObject,false,2); 88 | contentsObject.project.changes=[]; 89 | stringg = JSON.stringify(contentsObject); 90 | } 91 | } 92 | 93 | if(!id || stringg.length >= maxStringWriteLength) { 94 | console.error(`skipping writing project ${id} because its too long or noname`); 95 | return; 96 | } 97 | let filename = dir+path.sep+id; 98 | await fsp.writeFile(filename,stringg).catch(e=>{console.error('Error when saving filename:');console.error(e);}); 99 | } 100 | } 101 | export function loadMapFromFolder(dir) { 102 | let obj = {}; 103 | // check that directory exists, otherwise return empty obj 104 | if(!fs.existsSync(dir)) {return obj;} 105 | // add promises 106 | fs.readdirSync(dir,{withFileTypes:true}) 107 | .filter(dirent=>dirent.isFile()) 108 | .map(dirent=>([dirent.name,fs.readFileSync(dir + path.sep + dirent.name)])) 109 | .forEach(entry=>{ 110 | try{ 111 | obj[entry[0]] = JSON.parse(entry[1]); // parse file to object 112 | } catch (e) { 113 | console.error('json parse error on file: ' + dir + path.sep + '\x1b[1m' /* <- bold */ + entry[0] + '\x1b[0m' /* <- reset */); 114 | fs.rmSync(dir + path.sep + entry[0]); 115 | } 116 | }); 117 | return obj; 118 | } 119 | 120 | export function loadMapFromFolderRecursive(dir) { 121 | let obj = {}; 122 | 123 | // Check that the directory exists; otherwise, return an empty object 124 | if (!fs.existsSync(dir)) { 125 | return obj; 126 | } 127 | 128 | // Read directory contents 129 | const dirents = fs.readdirSync(dir, { withFileTypes: true }); 130 | 131 | for (const dirent of dirents) { 132 | const fullPath = path.join(dir, dirent.name); 133 | 134 | if (dirent.isFile()) { 135 | try { 136 | // Parse the file's contents as JSON 137 | if (dirent.name=='banned') { 138 | obj[dirent.name] = bannedList(); 139 | } else { 140 | const content = fs.readFileSync(fullPath, 'utf8'); 141 | obj[dirent.name] = JSON.parse(content); 142 | } 143 | } catch (e) { 144 | console.error( 145 | 'JSON parse error on file: ' + 146 | fullPath + 147 | '\x1b[1m' + // bold text 148 | dirent.name + 149 | '\x1b[0m', // reset text 150 | ); 151 | fs.rmSync(fullPath); // Remove the file if parsing fails 152 | } 153 | } else if (dirent.isDirectory()) { 154 | // If it's a directory, call the function recursively 155 | obj[dirent.name] = loadMapFromFolderRecursive(fullPath); 156 | } 157 | } 158 | 159 | return obj; 160 | } 161 | 162 | async function saveAsync(sessionManager) { 163 | if(isFinalSaving) {return;} // dont final save twice 164 | 165 | console.log('saving now...'); 166 | await sleep(10); // in case there is an error that nans lastid out 167 | 168 | const dirPath = path.dirname(lastIdPath); 169 | await fsp.mkdir(dirPath, { recursive: true }); 170 | 171 | await fsp.writeFile(lastIdPath,(sessionManager.lastId).toString()); 172 | await fsp.writeFile(freePassesPath,JSON.stringify(freePasses)); 173 | 174 | // DONT SAVE LIVESCRATCH PROJECTS BECAUSE ITS TOO COMPUTATIONALLY EXPENSIVE AND IT HAPPENS ANYWAYS ON OFFLOAD 175 | 176 | await saveRecent(); 177 | } 178 | export async function saveLoop(sessionManager) { 179 | while(true) { 180 | try{ await saveAsync(sessionManager); } 181 | catch (e) { console.error(e); } 182 | await sleep(30 * 1000); 183 | } 184 | } 185 | 186 | export function ban(username) { 187 | return new Promise((resolve, reject) => { 188 | try { 189 | if(!(bannedList().includes(username))) { 190 | fs.writeFileSync(bannedPath, (username + '\n'), { flag: 'a' }); 191 | } 192 | resolve(); 193 | } catch(err) { 194 | reject(err); 195 | } 196 | }); 197 | } 198 | 199 | export function unban(username) { 200 | return new Promise((resolve, reject) => { 201 | try { 202 | const banned = bannedList(); 203 | const updatedList = banned.filter(user => user !== username); 204 | fs.writeFileSync(bannedPath, updatedList.join('\n'), 'utf-8'); 205 | resolve(); 206 | } catch(err) { 207 | reject(err); 208 | } 209 | }); 210 | } 211 | 212 | export function getBanned(promise = true) { 213 | if (!promise) { 214 | return bannedList(); 215 | } 216 | 217 | return Promise.resolve(bannedList()); 218 | } -------------------------------------------------------------------------------- /backend/utils/profanity-filter.js: -------------------------------------------------------------------------------- 1 | import { ProfanityEngine } from '@coffeeandfun/google-profanity-words'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export class Filter { 6 | constructor(language = 'en') { 7 | this.profanityEngine = new ProfanityEngine({ language }); // Initialize with specified language 8 | this.leetspeakMap = {}; 9 | this.loadLeetspeakMap(); 10 | } 11 | 12 | /** 13 | * Load the leetspeak map from the JSON file. 14 | */ 15 | loadLeetspeakMap() { 16 | try { 17 | const filePath = path.resolve(process.cwd(), 'utils', '_leetspeakMap.json'); // Adjust path as needed 18 | const data = fs.readFileSync(filePath, 'utf-8'); // Synchronously read the JSON file 19 | this.leetspeakMap = JSON.parse(data); // Parse and assign to the map 20 | console.log('Leetspeak map loaded successfully:', this.leetspeakMap); 21 | } catch (error) { 22 | console.error('Error loading leetspeak map:', error.message); 23 | throw new Error('Failed to load leetspeak map'); 24 | } 25 | } 26 | 27 | /** 28 | * Normalize text by converting leetspeak to plain text. 29 | * @param {string} text - Input text to normalize. 30 | * @returns {string} - Normalized text. 31 | */ 32 | normalizeLeetspeak(text) { 33 | return text.replace(/[@$3107!5]/g, char => this.leetspeakMap[char] || char); 34 | } 35 | 36 | /** 37 | * Check if the given content contains bad words. 38 | * @param {string} content - The input string to check for profanity. 39 | * @returns {Promise<{ isBad: boolean, badWordsTotal: number, badWordsList: string[], censoredContent: string }>} 40 | */ 41 | async checkContent(content) { 42 | try { 43 | // Normalize content to handle leetspeak 44 | const normalizedContent = this.normalizeLeetspeak(content); 45 | 46 | // Check if the content contains curse words 47 | const hasCurseWords = await this.profanityEngine.hasCurseWords(normalizedContent); 48 | 49 | // Retrieve all bad words from the package (static list) 50 | const badWordsList = hasCurseWords 51 | ? (await this.profanityEngine.all()).filter(word => 52 | new RegExp(`\\b${word}\\b`, 'i').test(normalizedContent), 53 | ) 54 | : []; 55 | 56 | // Generate censored content 57 | const censoredContent = badWordsList.reduce( 58 | (censored, word) => 59 | censored.replace(new RegExp(`\\b${word}\\b`, 'gi'), '*'.repeat(word.length)), 60 | normalizedContent, 61 | ); 62 | 63 | return { 64 | isBad: hasCurseWords, 65 | badWordsTotal: badWordsList.length, 66 | badWordsList, 67 | censoredContent, 68 | }; 69 | } catch (error) { 70 | console.error('Error checking content:', error.message); 71 | throw new Error('Failed to process content with ProfanityEngine'); 72 | } 73 | } 74 | 75 | /** 76 | * Determines if the input text is vulgar. 77 | * @param {string} text - The input text. 78 | * @returns {Promise} - Returns true if vulgar, otherwise false. 79 | */ 80 | async isVulgar(text) { 81 | const result = await this.checkContent(text); 82 | return result.isBad; 83 | } 84 | 85 | /** 86 | * Gets the censored version of the input text. 87 | * @param {string} text - The input text. 88 | * @returns {Promise} - Returns the censored text. 89 | */ 90 | async getCensored(text) { 91 | const result = await this.checkContent(text); 92 | return result.censoredContent || text; // Return censored content if available 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /backend/utils/recentUsers.js: -------------------------------------------------------------------------------- 1 | import fsp from 'fs/promises'; 2 | import fs from 'fs'; 3 | import cron from 'node-cron'; 4 | import path from 'path'; 5 | 6 | const recentPath = 'storage/recent.json'; 7 | 8 | //load from file 9 | let {recent,recentRealtime,recentShared,popup} = fs.existsSync(recentPath) ? JSON.parse(fs.readFileSync(recentPath)) : {recent:{},recentRealtime:{},recentShared:{},popup:[]}; 10 | if(!recent) {recent = {};} 11 | if(!recentRealtime) {recentRealtime = {};} 12 | if(!recentShared) {recentShared = {};} 13 | if(!popup) {popup = [];} 14 | 15 | const CRON_EXPRESSION = '0 1 * * *'; // every night at 1am 16 | cron.schedule(CRON_EXPRESSION, async () => { 17 | trimRecent(); 18 | },{ 19 | scheduled: true, 20 | timezone: 'Etc/GMT+3', 21 | }); 22 | 23 | setInterval(saveRecent,1000); 24 | 25 | // save to file 26 | export async function saveRecent() { 27 | const dirPath = path.dirname(recentPath); 28 | await fsp.mkdir(dirPath, { recursive: true }); 29 | 30 | await fsp.writeFile(recentPath,JSON.stringify({recent,recentRealtime,recentShared,popup})); 31 | } 32 | 33 | export function recordPopup(username) { 34 | username = username?.toLowerCase?.(); 35 | let toPush = {u:username,t:Date.now()}; 36 | popup.push(toPush); 37 | } 38 | export function countPopup(days) { 39 | let now = Date.now(); 40 | let millis = days * 1000 * 60 * 60 * 24; 41 | let count = popup.filter(record=>record.t>now-millis).length; 42 | return count; 43 | } 44 | export function countUniquePopup(days) { 45 | let now = Date.now(); 46 | let millis = days * 1000 * 60 * 60 * 24; 47 | let count = new Set(popup.filter(record=>record.t>now-millis).map(record=>record.u)).size; 48 | return count; 49 | } 50 | 51 | export function addRecent(username,realtime,shared) { 52 | username=username?.toLowerCase?.(); 53 | recent[username] = Date.now(); 54 | if(realtime) { 55 | recentRealtime[username] = Date.now(); 56 | } 57 | if(shared) { 58 | recentShared[username] = Date.now(); 59 | } 60 | } 61 | 62 | // remove older than 30 days 63 | function trimRecent() { 64 | const DAYS = 30; 65 | 66 | let namesToDelete = Object.entries(recent).filter(entry=>(Date.now()-entry[1]>1000*60*60*24*DAYS)).map(entry=>entry[0]); 67 | namesToDelete.forEach(name=>{delete recent[name];}); 68 | 69 | let namesToDeleteRealtime = Object.entries(recentRealtime).filter(entry=>(Date.now()-entry[1]>1000*60*60*24*DAYS)).map(entry=>entry[0]); 70 | namesToDeleteRealtime.forEach(name=>{delete recentRealtime[name];}); 71 | 72 | let namesToDeleteShared = Object.entries(recentShared).filter(entry=>(Date.now()-entry[1]>1000*60*60*24*DAYS)).map(entry=>entry[0]); 73 | namesToDeleteShared.forEach(name=>{delete recentShared[name];}); 74 | 75 | popup = popup.filter(entry=>(Date.now()-entry.t)<1000*60*60*24*DAYS); 76 | } 77 | 78 | export function countRecentShared(days) { 79 | const DAYS = days; 80 | return Object.entries(recentShared).filter(entry=>(Date.now()-entry[1]<1000*60*60*24*DAYS)).length; 81 | } 82 | export function countRecentRealtime(days) { 83 | const DAYS = days; 84 | return Object.entries(recentRealtime).filter(entry=>(Date.now()-entry[1]<1000*60*60*24*DAYS)).length; 85 | } 86 | export function countRecent(days) { 87 | const DAYS = days; 88 | return Object.entries(recent).filter(entry=>(Date.now()-entry[1]<1000*60*60*24*DAYS)).length; 89 | } 90 | export function countRecentBoth(days) { 91 | return { 92 | all:countRecent(), 93 | realtime:countRecentRealtime(), 94 | shared:countRecentShared(), 95 | }; 96 | } 97 | 98 | -------------------------------------------------------------------------------- /backend/utils/removeOldProjects.js: -------------------------------------------------------------------------------- 1 | /// for some reason this causes a ton of issues :/ 2 | 3 | import { livescratchPath, scratchprojectsPath } from './fileStorage.js'; 4 | import fs from 'fs'; 5 | import cron from 'node-cron'; 6 | 7 | function sleep(millis) { 8 | return new Promise(res => setTimeout(res, millis)); 9 | } 10 | 11 | let inprog=false; 12 | export function installCleaningJob(sessionManager, userManager) { 13 | // removeOldProjectsAsync(sessionManager, userManager); 14 | // removeUntetheredScratchprojects(sessionManager,userManager) 15 | cron.schedule(CRON_EXPRESSION, async () => { 16 | if(inprog) {return;} // dont do it twice 17 | inprog=true; 18 | await removeOldProjectsAsync(sessionManager, userManager); 19 | await removeUntetheredScratchprojects(sessionManager,userManager); 20 | inprog=false; 21 | },{ 22 | scheduled: true, 23 | timezone: 'Asia/Qatar', 24 | }); 25 | } 26 | 27 | const HOW_OLD_DAYS = 60; // delete projects with no new edits in the last this number of days 28 | const CRON_EXPRESSION = '0 2 * * *'; // every night at 2am 29 | 30 | async function removeOldProjectsAsync(sessionManager, userManager) { 31 | fs.readdir(livescratchPath, async (err, files) => { 32 | console.log('removal test started', files); 33 | for (let id of files) { 34 | await sleep(55); // rate limit might fix issues??????? IM LOSSTTTTTTTT!!! 35 | try { 36 | 37 | console.log('probing project with id ' + id); 38 | let project = sessionManager.getProject(id); 39 | if (!project) { 40 | console.log('project doesnt exist, DELETING id ' + id); 41 | sessionManager.deleteProjectFile(id); // WARNING- WILL DELETE ALL PROJECTS IF TOO MANY FILES ARE OPEN. CONSIDER REMOVING THIS LINE IN THE FUTURE WHEN LIVESCRATCH HAS TOO MANY FOLKS 42 | } //todo check if project not existing messes up delete function 43 | else { // if project does exist 44 | id = project.id; // since we know that project.id exists 45 | 46 | if (Object.keys(project.session.connectedClients).length == 0) { 47 | if (project.project.lastTime && Date.now() - new Date(project.project.lastTime) > HOW_OLD_DAYS * 24 * 60 * 60 * 1000) { 48 | 49 | console.log(`deleting project ${id} because it is old`); 50 | 51 | [project.owner, ...project.sharedWith].forEach(username => { 52 | userManager.unShare(username, id); 53 | sessionManager.unshareProject(id, username); 54 | }); 55 | 56 | sessionManager.deleteProjectFile(id); 57 | } else { 58 | project.trimChanges(); 59 | await sessionManager.offloadProjectAsync(id); 60 | } 61 | } 62 | } 63 | } 64 | catch (e) { 65 | console.error(`error while probing project ${id}:`, e); 66 | } 67 | } 68 | }); 69 | } 70 | 71 | 72 | async function removeUntetheredScratchprojects(sessionManager, userManager) { 73 | 74 | fs.readdir(scratchprojectsPath, async (err, files) => { 75 | console.log('removal scratchprojectsentries test started', files); 76 | for (let scratchid of files) { 77 | 78 | await sleep(60); 79 | let entry = sessionManager.getScratchProjectEntry(scratchid); 80 | if(!entry) { 81 | sessionManager.deleteScratchProjectEntry(scratchid); 82 | continue; 83 | } 84 | let id = entry.blId; 85 | let project = sessionManager.getProject(id); 86 | if (!project) { 87 | sessionManager.deleteScratchProjectEntry(scratchid); 88 | continue; 89 | } 90 | } 91 | }, 92 | ); 93 | } -------------------------------------------------------------------------------- /backend/utils/scratch-auth.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | export const freePassesPath = 'storage/freePasses.json'; 3 | export const failedAuthLog = {}; 4 | export const secondTimeSuccessAuthLog = {}; 5 | const authProjects = JSON.parse(process.env.AUTH_PROJECTS); 6 | const admin = JSON.parse(process.env.ADMIN); 7 | 8 | 9 | function logAuth(username, success, word, info) { 10 | if (!username) { return; } 11 | if (success) { 12 | if(word!='authenticate'){console.log(`✅ Successfully ${word}ed user ${username}`);} 13 | if (username in failedAuthLog) { 14 | delete failedAuthLog[username]; 15 | secondTimeSuccessAuthLog[username] = true; 16 | } 17 | } else { 18 | failedAuthLog[username] = (failedAuthLog[username] instanceof Array) ? (failedAuthLog[username].length > 10 ? failedAuthLog[username] : [...failedAuthLog[username] ,info]) : [info]; 19 | console.error(`🆘 Failed to ${word} user ${username}`); 20 | 21 | } 22 | } 23 | 24 | let pendingMap = {}; // publicAuthCode : clientSecret 25 | 26 | function sleep(millis) { 27 | return new Promise(res => setTimeout(res, millis)); 28 | } 29 | 30 | 31 | let idIndex = 0; 32 | export function getAuthStats() { 33 | return { idIndex, info: getAuthProjectId(), failed: Object.keys(failedAuthLog).length, secondTimeSuccessCount:Object.keys(secondTimeSuccessAuthLog).length }; 34 | } 35 | 36 | function generateAuthCode() { 37 | return Math.floor(Math.random() * 1000000).toString(); 38 | } 39 | 40 | function getAuthProjectId() { 41 | return authProjects[idIndex]; 42 | } 43 | 44 | let userManager; 45 | let sessionManager; 46 | export function setPaths(app, userManagerr, sessionManagerr) { 47 | userManager = userManagerr; 48 | sessionManager = sessionManagerr; 49 | app.get('/verify/start', (req, res) => { // ?code=000000 50 | let debugUname = req.headers.uname; 51 | console.log(`starting to authenticate user ${debugUname}`); 52 | 53 | let clientCode = req.query.code; 54 | let verifyCode = generateAuthCode(); 55 | 56 | pendingMap[clientCode] = verifyCode; 57 | setTimeout(()=>{delete pendingMap[clientCode];},1000 * 60); // delete pending verifications after one minute 58 | res.send({ code: verifyCode, project: getAuthProjectId() }); 59 | }); 60 | 61 | const CLOUD_WAIT = 1000 * 5; 62 | app.get('/verify/userToken', async (req, res) => { // ?code=000000&method=cloud|CLOUDs 63 | try { 64 | let clientCode = req.query.code; 65 | if (!clientCode) { res.send({ err: 'no client code included' }); return; } 66 | let tempCode = pendingMap[clientCode]; 67 | 68 | if (!tempCode) { 69 | res.send({ err: 'client code not found', clientCode }); 70 | return; 71 | } 72 | 73 | let cloud = await getVerificationCloud(tempCode); 74 | if (!cloud || cloud?.err) { 75 | console.log(`retrying... ${req.headers.uname}`); 76 | await sleep(CLOUD_WAIT); 77 | cloud = await getVerificationCloud(); 78 | } 79 | if (cloud?.code == 'nocon') { 80 | grantFreePass(req.headers.uname); 81 | logAuth(req.headers.uname, true, 'verify', 'server couldn\'t query cloud'); 82 | res.send({ freepass: true }); 83 | return; 84 | } 85 | if (!cloud) { 86 | res.send({ err: 'no cloud' }); 87 | logAuth(req.headers.uname, false, 'verify', 'no cloud var found'); 88 | return; 89 | } 90 | console.log('cloud', cloud); 91 | delete pendingMap[tempCode]; 92 | 93 | let username = cloud.user; 94 | let token = userManagerr.getUser(username)?.token; 95 | if (!token) { 96 | res.send({ err: 'user not found', username }); 97 | logAuth(username, false, 'verify', 'user not stored in database'); 98 | return; 99 | } 100 | 101 | deleteFreePass(username); 102 | res.send({ token, username }); 103 | logAuth(username, true, 'verify', 'success'); 104 | return; 105 | } catch (err) { 106 | next(err); 107 | } 108 | }); 109 | app.post('/verify/recordError',(req,res)=>{ 110 | let message = req.body.msg; 111 | let username = req.headers.uname; 112 | logAuth(username,false,'set cloud',message); 113 | console.log('msg',message); 114 | res.end(); 115 | }); 116 | } 117 | 118 | let cachedCloud = []; 119 | let cachedTime = 0; 120 | let CLOUD_CHECK_RATELIMIT = 1000 * 2; // every 2 seconds 121 | 122 | async function checkCloud() { 123 | try { 124 | cachedCloud = await (await fetch(`https://clouddata.scratch.mit.edu/logs?projectid=${getAuthProjectId()}&limit=40&offset=0&rand=${Math.random()}`)).json(); 125 | cachedTime = Date.now(); 126 | return cachedCloud; 127 | } catch (e) { 128 | console.error(e); 129 | cachedCloud = { code: 'nocon' }; 130 | idIndex = (idIndex + 1) % authProjects.length; 131 | return cachedCloud; 132 | } 133 | } 134 | let checkCloudPromise = null; 135 | async function queueCloudCheck() { 136 | if (checkCloudPromise) { return checkCloudPromise; } 137 | return checkCloudPromise = new Promise(res => setTimeout(async () => { 138 | await checkCloud(); 139 | checkCloudPromise = null; 140 | res(cachedCloud); 141 | }, CLOUD_CHECK_RATELIMIT)); 142 | } 143 | async function checkCloudRatelimited() { 144 | if (Date.now() - cachedTime < CLOUD_CHECK_RATELIMIT) { 145 | return await queueCloudCheck(); 146 | } else { 147 | return await checkCloud(); 148 | } 149 | } 150 | 151 | async function getVerificationCloud(tempCode) { 152 | let vars = await checkCloudRatelimited(); 153 | if (vars?.code) { return { code: 'nocon' }; }; 154 | let cloud = vars?.map(cloudObj => ({ content: cloudObj?.value, user: cloudObj?.user })); 155 | cloud = cloud.filter(com => String(com.content) == String(tempCode)).reverse()[0]; 156 | return cloud; 157 | } 158 | 159 | 160 | // export let freePasses = {} // username : passtime 161 | 162 | export let freePasses = fs.existsSync(freePassesPath) ? JSON.parse(fs.readFileSync(freePassesPath)) : {}; 163 | // grant temporary free verification to users if the livescratch server fails to verify 164 | export function grantFreePass(username) { 165 | console.error('granted free pass to user ' + username); 166 | username = username?.toLowerCase?.(); 167 | freePasses[username] = Date.now(); 168 | } 169 | export function hasFreePass(username) { 170 | username = username?.toLowerCase?.(); 171 | return username in freePasses; 172 | } 173 | export function deleteFreePass(username) { 174 | username = username?.toLowerCase?.(); 175 | if (username in freePasses) { 176 | console.error('removing free pass from user ' + username); 177 | delete freePasses[username]; 178 | } 179 | } 180 | 181 | 182 | export function authenticate(username, token, bypassBypass) { 183 | if (!bypassBypass) { return true; } 184 | if(!username) { console.error(`undefined username attempted to authenticate with token ${token}`); return '*';} 185 | let success = hasFreePass(username) || userManager.getUser(username).token == token; 186 | if (success) { 187 | logAuth(username, true, 'authenticate'); 188 | // mark as active 189 | if(!hasFreePass(username)) { userManager.getUser(username).verified = true; } 190 | } else { 191 | logAuth(username, false, 'authenticate', `failed to authenticate with token "${token}"`); 192 | // console.error(`🟪 User Authentication failed for user: ${username}, bltoken: ${token}`) 193 | 194 | } 195 | return success; 196 | } 197 | 198 | export let numWithCreds = 0; 199 | export let numWithoutCreds = 0; 200 | export function fullAuthenticate(username,token,lsId,bypassAuth) { 201 | if(token) {numWithCreds++;} 202 | else {numWithoutCreds++;} 203 | if(!username) { console.error(`undefined username attempted to authenticate on project ${lsId} with token ${token}`); username = '*';} 204 | let userAuth = authenticate(username,token,bypassAuth); 205 | let isUserbypassAuth = (!bypassAuth); 206 | let authAns = ((userAuth || isUserbypassAuth)) && (sessionManager.canUserAccessProject(username,lsId) || 207 | admin.includes(username)); 208 | if(!authAns && (userAuth || isUserbypassAuth)) { 209 | console.error(`🟪☔️ Project Authentication failed for user: ${username}, lstoken: ${token}, lsId: ${lsId}`); 210 | } 211 | return authAns; 212 | } -------------------------------------------------------------------------------- /backend/utils/sessionManager.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import fsp from 'fs/promises'; 3 | import path, { sep } from 'path'; 4 | import sanitize from 'sanitize-filename'; 5 | import { livescratchPath, lastIdPath, saveMapToFolder, saveMapToFolderAsync, scratchprojectsPath } from './fileStorage.js'; 6 | import { Blob } from 'node:buffer'; 7 | import { countPopup, countRecent, countRecentRealtime, countRecentShared, countUniquePopup } from './recentUsers.js'; 8 | import { getAuthStats } from './scratch-auth.js'; 9 | import { numWithCreds, numWithoutCreds } from './scratch-auth.js'; 10 | 11 | const ensureDirectoryExistence = (filePath) => { 12 | const dirname = path.dirname(filePath); 13 | if (fs.existsSync(dirname)) { 14 | return true; 15 | } 16 | ensureDirectoryExistence(dirname); 17 | fs.mkdirSync(dirname); 18 | }; 19 | 20 | const OFFLOAD_TIMEOUT_MILLIS = 45 * 1000; // you get two minutes before the project offloads 21 | 22 | class LivescratchProject { 23 | 24 | // a note on project versioning: 25 | // a change's version refers to the version that the project is on after a change is played 26 | // a jsonVersion refers to the version of the last change included in a json 27 | // the next change to be played must be a client's current blVersion + 1 28 | 29 | static fromJSON(json) { 30 | let ret = new LivescratchProject(json.title); 31 | Object.entries(json).forEach(entry => { 32 | ret[entry[0]] = entry[1]; 33 | }); 34 | return ret; 35 | } 36 | 37 | // toJSON() { // this function makes it so that the file writer doesnt save the change log. remove it to re-implement saving the change log 38 | // let ret = { ...this } 39 | 40 | // let n = 5; // trim changes on save 41 | // n = Math.min(n, ret.changes.length); 42 | 43 | // ret.indexZeroVersion += ret.changes.length - n; 44 | // ret.changes = ret.changes.slice(-n) 45 | 46 | // // if the changes list string is more than 20 mb, dont write changes 47 | // if (new Blob([JSON.stringify(ret.changes)]).size > 2e7) { 48 | // ret.indexZeroVersion += ret.changes.length 49 | // ret.changes = []; 50 | // } 51 | 52 | // return ret; 53 | // } 54 | 55 | 56 | // projectJSON 57 | // projectJSONVersion = 0 58 | version = -1; 59 | changes = []; 60 | lastTime = Date.now(); 61 | lastUser = ''; 62 | title; 63 | 64 | constructor(title) { 65 | this.title = title; 66 | } 67 | 68 | recordChange(change) { 69 | this.trimCostumeEdits(change); 70 | this.changes.push(change); 71 | this.version++; 72 | this.lastTime = Date.now(); 73 | } 74 | 75 | // removes previous bitmap/svg updates of same sprite to save loading time 76 | trimCostumeEdits(newchange) { 77 | if (newchange.meta == 'vm.updateBitmap' || newchange.meta == 'vm.updateSvg') { 78 | let target = newchange.target; 79 | let costumeIndex = newchange.costumeIndex; 80 | let limit = 20; 81 | for (let i = this.changes.length - 1; i >= 0 && i >= this.changes.length - limit; i--) { 82 | let change = this.changes[i]; 83 | let spn = change?.data?.name; 84 | if (spn == 'reordercostume' || spn == 'renamesprite') { break; } 85 | if ((change.meta == 'vm.updateBitmap' || change.meta == 'vm.updateSvg') && change.target == target && change.costumeIndex == costumeIndex) { 86 | this.changes[i] = { meta: 'version++' }; 87 | } 88 | } 89 | } 90 | } 91 | 92 | getChangesSinceVersion(lastVersion) { 93 | return this.changes.slice(Math.max(0, lastVersion - this.getIndexZeroVersion())); 94 | } 95 | getIndexZeroVersion() { 96 | return this.version - this.changes.length + 1; 97 | } 98 | 99 | // trim changes to lenght n 100 | trimChanges(n) { 101 | // bound n: 0 < n < total changes lenght 102 | if (!n) { n = 0; } 103 | n = Math.min(n, this.changes.length); 104 | 105 | // this.indexZeroVersion += this.changes.length - n; 106 | this.changes = this.changes.slice(-n); 107 | // LOL DONT 108 | // for(let i=0; i client.username?.toLowerCase())))]; 162 | } 163 | 164 | // get one client per username 165 | getConnectedUsersClients() { 166 | let clients = {}; 167 | Object.values(this.connectedClients).forEach(client => { clients[client.username.toLowerCase()] = client; }); 168 | return clients; 169 | } 170 | 171 | sendChangeFrom(socket, msg, excludeVersion) { 172 | Object.values(this.connectedClients).forEach(client => { 173 | if (!socket || (socket.id != client.id())) { 174 | // console.log('sending message to: ' + client.username + " | type: " + msg.type) 175 | client.trySendMessage({ 176 | type: 'projectChange', 177 | blId: this.id, 178 | version: excludeVersion ? null : this.project.version, 179 | msg, 180 | from: socket?.id, 181 | user: this.getClientFromSocket(socket)?.username, 182 | }); 183 | } 184 | }); 185 | } 186 | 187 | onProjectChange(socket, msg) { 188 | let client = this.getClientFromSocket(socket); 189 | msg.user = client?.username; 190 | this.project.recordChange(msg); 191 | this.project.lastUser = client ? client.username : this.project.lastUser; 192 | this.sendChangeFrom(socket, msg); 193 | } 194 | 195 | getWonkySockets() { 196 | let wonkyKeys = []; 197 | Object.entries(this.connectedClients).forEach(entry => { 198 | let socket = entry[1].socket; 199 | if (socket.disconnected || socket.id != entry[0]) { 200 | wonkyKeys.push(entry[0]); 201 | console.log('WONKINESS DETECTED! disconnected:', socket.disconnected, 'wrong id', ocket.id != entry[0]); 202 | } 203 | // if(Object.keys(this.connectedClients).length == 0) { 204 | // // project.project.trimChanges(20) 205 | // this.offloadProject(id) // find way to access this function 206 | // } 207 | }); 208 | return wonkyKeys; 209 | } 210 | } 211 | 212 | class ProjectWrapper { 213 | 214 | toJSON() { 215 | let ret = { 216 | project: this.project, 217 | id: this.id, 218 | scratchId: this.scratchId, 219 | projectJson: this.projectJson, 220 | jsonVersion: this.jsonVersion, 221 | linkedWith: this.linkedWith, 222 | owner: this.owner, 223 | sharedWith: this.sharedWith, 224 | chat: this.chat, 225 | }; 226 | return ret; 227 | } 228 | 229 | static fromJSON(json) { 230 | let ret = new ProjectWrapper('&'); 231 | Object.entries(json).forEach(entry => { 232 | if (entry[0] != 'project') { 233 | ret[entry[0]] = entry[1]; 234 | } 235 | }); 236 | ret.project = LivescratchProject.fromJSON(json.project); 237 | ret.session = new LivescratchSess(ret.project, ret.id); 238 | return ret; 239 | } 240 | 241 | session; 242 | project; 243 | 244 | // livescratch id 245 | id; 246 | // most recently saved json 247 | projectJson; 248 | // json version 249 | jsonVersion = 0; 250 | 251 | // // most up to date scratch project id 252 | scratchId; 253 | // // index of next change i think 254 | // scratchVersion = 0 255 | linkedWith = []; // {scratchId, owner} 256 | 257 | owner; 258 | sharedWith = []; 259 | 260 | chat = []; 261 | static defaultChat = { sender: 'Livescratch', text: 'Welcome! Chat is public, monitored, and filtered. Report inappropriate things and bugs to @Waakul.' }; 262 | 263 | constructor(owner, scratchId, projectJson, blId, title) { 264 | if (owner == '&') { return; } 265 | this.id = blId; 266 | this.owner = owner; 267 | this.projectJson = projectJson; 268 | this.scratchId = scratchId; 269 | this.project = new LivescratchProject(title); 270 | this.session = new LivescratchSess(this.project, this.id); 271 | this.linkedWith.push({ scratchId, owner }); 272 | this.chat.push(ProjectWrapper.defaultChat); 273 | } 274 | 275 | 276 | onChat(msg, socket) { 277 | this.chat.push(msg.msg); 278 | this.session.sendChangeFrom(socket, msg, true); 279 | this.trimChat(500); 280 | } 281 | getChat() { 282 | return this.chat; 283 | } 284 | trimChat(n) { 285 | // bound n: 0 < n < total changes lenght 286 | if (!n) { n = 0; } 287 | n = Math.min(n, this.chat.length); 288 | this.chat = this.chat.slice(-n); 289 | } 290 | serverSendChat(message, from) { 291 | if (!from) { from = 'Livescratch'; } 292 | let msg = { 293 | 'meta': 'chat', 294 | 'msg': { 295 | 'sender': from, 296 | 'text': message, 297 | 'linkify': true, 298 | }, 299 | }; 300 | this.session.sendChangeFrom(null, msg, true); 301 | } 302 | 303 | trimChanges(n) { // defaults to trimming to json version 304 | if(!n) {n = this.project.version - this.jsonVersion;} 305 | this.project.trimChanges(n); 306 | } 307 | 308 | // scratchSaved(id,version) { 309 | // // dont replace scratch id if current version is already ahead 310 | // if(version <= this.scratchVersion) {console.log('version too low. not recording. most recent version & id:',this.scratchVersion, this.scratchId);return} 311 | // this.scratchId = id 312 | // this.scratchVersion = version 313 | // console.log('linkedWith length', this.linkedWith.length) 314 | // this.linkedWith.find(proj=>proj.scratchId == id).version = version 315 | // } 316 | 317 | isSharedWith(username) { 318 | return username == this.owner || this.sharedWith.includes(username); 319 | } 320 | isSharedWithCaseless(username) { 321 | username=String(username).toLowerCase(); 322 | let owner = String(this.owner).toLowerCase(); 323 | let sharedWithLowercase=this.sharedWith.map(un=>String(un).toLowerCase()); 324 | return username == owner || sharedWithLowercase.includes(username); 325 | } 326 | 327 | scratchSavedJSON(json, version) { 328 | if (version <= this.jsonVersion) { console.log('version too low. not recording. most recent version & id:', this.jsonVersion); return; } 329 | this.projectJson = json; 330 | this.jsonVersion = version; 331 | this.trimChanges(); 332 | // console.log('linkedWith length', this.linkedWith.length) 333 | // this.linkedWith.find(proj=>proj.scratchId == id).version = version 334 | } 335 | 336 | linkProject(scratchId, owner) { 337 | this.linkedWith.push({ scratchId, owner }); 338 | // this.linkedWith.push({scratchId,owner,version}) 339 | } 340 | 341 | // returns {scratchId, owner} 342 | getOwnersProject(owner) { 343 | return this.linkedWith.find(project => project.owner?.toLowerCase() == owner?.toLowerCase()); 344 | } 345 | 346 | joinSession(socket, username) { 347 | if (socket.id in this.session.connectedClients) { return; } 348 | let client = new LivescratchClient(socket, username); 349 | this.session.addClient(client); 350 | if (!this.project.lastUser) { this.project.lastUser = username; } 351 | } 352 | } 353 | 354 | export default class SessionManager { 355 | 356 | toJSON() { 357 | let ret = { 358 | // scratchprojects:this.scratchprojects, //todo return only changed projects 359 | livescratch: this.livescratch, 360 | lastId: this.lastId, 361 | }; 362 | return ret; 363 | 364 | } 365 | static fromJSON(ob) { 366 | let ret = new SessionManager(); 367 | // if(ob.scratchprojects) { ret.scratchprojects = ob.scratchprojects; } 368 | if (ob.lastId) { ret.lastId = ob.lastId; } 369 | if (ob.livescratch) { 370 | Object.entries(ob.livescratch).forEach(entry => { 371 | ret.livescratch[entry[0]] = ProjectWrapper.fromJSON(entry[1]); 372 | }); 373 | } 374 | 375 | return ret; 376 | } 377 | 378 | static inst; 379 | 380 | 381 | // map scratch project id's to info objects {owner, blId} 382 | // scratchprojects = {} 383 | // id -> ProjectWrapper 384 | livescratch = {}; 385 | socketMap = {}; 386 | 387 | lastId = 0; 388 | 389 | constructor() { 390 | SessionManager.inst = this; 391 | } 392 | 393 | // Deprecated 394 | offloadStaleProjects() { 395 | Object.entries(this.livescratch).forEach(entry => { 396 | let project = entry[1]; 397 | let id = entry[0]; 398 | if (Object.keys(project.session.connectedClients).length == 0) { 399 | project.project.trimChanges(20); 400 | this.offloadProject(id); 401 | } 402 | }); 403 | } 404 | finalSaveAllProjects() { 405 | Object.entries(this.livescratch).forEach(entry => { 406 | let project = entry[1]; 407 | let id = entry[0]; 408 | project.trimChanges(); 409 | }); 410 | saveMapToFolder(this.livescratch, livescratchPath); 411 | this.livescratch = {}; 412 | } 413 | async finalSaveAllProjectsAsync() { 414 | Object.entries(this.livescratch).forEach(entry => { 415 | let project = entry[1]; 416 | let id = entry[0]; 417 | project.trimChanges(); 418 | }); 419 | await saveMapToFolderAsync(this.livescratch, livescratchPath,false,true); 420 | this.livescratch = {}; 421 | } 422 | // Deprecated 423 | async offloadStaleProjectsAsync() { 424 | for (let entry of Object.entries(this.livescratch)) { 425 | let project = entry[1]; 426 | let id = entry[0]; 427 | if (Object.keys(project.session.connectedClients).length == 0) { 428 | project.project.trimChanges(20); 429 | await this.offloadProjectAsync(id); 430 | } 431 | } 432 | } 433 | offloadProjectIfStale(id) { 434 | let project = this.livescratch[id]; 435 | if (!project) { return; } 436 | if (Object.keys(project.session.connectedClients).length == 0) { 437 | project.trimChanges(); 438 | this.offloadProject(id); 439 | } else { 440 | this.renewOffloadTimeout(id); 441 | } 442 | } 443 | offloadProject(id) { 444 | try { 445 | // console.log('offloading project ' + id) 446 | this.livescratch[id]?.trimChanges(); 447 | let toSaveLivescratch = {}; 448 | toSaveLivescratch[id] = this.livescratch[id]; 449 | if (toSaveLivescratch[id]) { // only save it if there is actual data to save 450 | saveMapToFolder(toSaveLivescratch, livescratchPath); 451 | } 452 | delete this.livescratch[id]; 453 | } catch (e) { console.error(e); } 454 | } 455 | async offloadProjectAsync(id) { 456 | try { 457 | // console.log('offloading project ' + id) 458 | let toSaveLivescratch = {}; 459 | toSaveLivescratch[id] = this.livescratch[id]; 460 | if (toSaveLivescratch[id]) { // only save it if there is actual data to save 461 | await saveMapToFolderAsync(toSaveLivescratch, livescratchPath); 462 | } 463 | delete this.livescratch[id]; 464 | } catch (e) { console.error(e); } 465 | } 466 | reloadProject(id) { 467 | id = sanitize(id + ''); 468 | let filename = livescratchPath + path.sep + id; 469 | let d = null; 470 | if (!(id in this.livescratch) && fs.existsSync(filename)) { 471 | try { 472 | d = fs.openSync(filename); 473 | let file = fs.readFileSync(d); 474 | fs.closeSync(d); 475 | 476 | let json = JSON.parse(file); 477 | let project = ProjectWrapper.fromJSON(json); 478 | this.livescratch[id] = project; 479 | console.log('reloaded livescratch ' + id); 480 | 481 | 482 | } catch (e) { 483 | // if(!id) {return} 484 | console.error('reloadProject: couldn\'t read project with id: ' + id + '. err msg: ', e); 485 | 486 | // if(d) { 487 | // try{fs.closeSync(d)} 488 | // catch(e) {console.error(e)} 489 | // } 490 | } 491 | } 492 | } 493 | async reloadProjectAsync(id) { 494 | 495 | id = sanitize(id + ''); 496 | if (!(id in this.livescratch)) { 497 | try { 498 | 499 | let file = await fsp.readFile(livescratchPath + path.sep + id); 500 | 501 | let json = JSON.parse(file); 502 | let project = ProjectWrapper.fromJSON(json); 503 | this.livescratch[id] = project; 504 | console.log('reloaded livescratch ' + id); 505 | } catch (e) { 506 | // if(!id) {return} 507 | console.error('reloadProject: couldn\'t read project with id: ' + id + '. err msg: ', e); 508 | } 509 | } 510 | } 511 | 512 | linkProject(id, scratchId, owner, version) { 513 | let project = this.getProject(id); 514 | if (!project) { return; } 515 | project.linkProject(scratchId, owner, version); 516 | // this.scratchprojects[scratchId] = {owner,blId:id} 517 | this.makeScratchProjectEntry(scratchId, owner, id); 518 | } 519 | 520 | // constructor(owner,scratchId,json,blId,title) { 521 | newProject(owner, scratchId, json, title) { 522 | if (this.doesScratchProjectEntryExist(scratchId)) { return this.getProject(this.getScratchProjectEntry(scratchId).blId); } 523 | let id = String(this.getNextId()); 524 | let project = new ProjectWrapper(owner, scratchId, json, id, title); 525 | this.livescratch[id] = project; 526 | this.makeScratchProjectEntry(scratchId, owner, id); 527 | // this.scratchprojects[scratchId] = {owner,blId:id} 528 | 529 | return project; 530 | } 531 | 532 | join(socket, id, username) { 533 | let project = this.getProject(id); 534 | if (!project) { return; } 535 | project.joinSession(socket, username); 536 | if (!(socket.id in this.socketMap)) { 537 | this.socketMap[socket.id] = { username: username, projects: [] }; 538 | } 539 | if (this.socketMap[socket.id].projects.indexOf(project.id) == -1) { 540 | this.socketMap[socket.id].projects.push(project.id); 541 | } 542 | console.log(username + ' joined | blId: ' + id + ', scratchId: ' + project.scratchId); 543 | } 544 | leave(socket, id, voidMap) { 545 | let project = this.getProject(id); 546 | if (!project) { return; } 547 | let username = project.session.removeClient(socket.id); 548 | if (socket.id in this.socketMap && !voidMap) { 549 | let array = this.socketMap[socket.id].projects; 550 | 551 | const index = array.indexOf(id); 552 | if (index > -1) { 553 | array.splice(index, 1); 554 | } 555 | } 556 | if (Object.keys(project.session.connectedClients).length == 0) { 557 | project.trimChanges(); 558 | this.offloadProject(id); 559 | } 560 | console.log(username + ' LEFT | blId: ' + id + ', scratchId: ' + project.scratchId); 561 | } 562 | 563 | disconnectSocket(socket) { 564 | if (!(socket.id in this.socketMap)) { return; } 565 | this.socketMap[socket.id].projects.forEach(projectId => { this.leave(socket, projectId, true); }); 566 | delete this.socketMap[socket.id]; 567 | } 568 | 569 | projectChange(blId, data, socket) { 570 | this.getProject(blId)?.session.onProjectChange(socket, data.msg); 571 | } 572 | 573 | getVersion(blId) { 574 | return this.getProject(blId)?.project.version; 575 | } 576 | 577 | getNextId() { 578 | this.lastId++; 579 | this.tryWriteLastId(); 580 | return this.lastId; 581 | } 582 | 583 | tryWriteLastId() { 584 | try { 585 | fs.writeFile(lastIdPath, this.lastId.toString(), () => { }); 586 | } catch (e) { console.error(e); } 587 | } 588 | 589 | // todo checking 590 | attachScratchProject(scratchId, owner, livescratchId) { 591 | this.makeScratchProjectEntry(scratchId, owner, livescratchId); 592 | // this.scratchprojects[scratchId] = {owner,blId:livescratchId} 593 | } 594 | 595 | offloadTimeoutIds = {}; 596 | renewOffloadTimeout(blId) { 597 | // clear previous timeout 598 | clearTimeout(this.offloadTimeoutIds[blId]); 599 | delete this.offloadTimeoutIds[blId]; 600 | // set new timeout 601 | let timeout = setTimeout(() => { this.offloadProjectIfStale(blId); }, OFFLOAD_TIMEOUT_MILLIS); 602 | this.offloadTimeoutIds[blId] = timeout; 603 | 604 | } 605 | 606 | getProject(blId) { 607 | this.renewOffloadTimeout(blId); 608 | this.reloadProject(blId); 609 | return this.livescratch[blId]; 610 | } 611 | async getProjectAsync(blId) { // untested attempt to avoid too many files open in node version 17.9.1 612 | this.renewOffloadTimeout(blId); 613 | await this.reloadProject(blId); 614 | return this.livescratch[blId]; 615 | } 616 | shareProject(id, user, pk) { 617 | console.log(`sessMngr: sharing ${id} with ${user} (usrId ${pk})`); 618 | let project = this.getProject(id); 619 | if (!project) { return; } 620 | project.sharedWith.push(user); 621 | } 622 | unshareProject(id, user) { 623 | console.log(`sessMngr: unsharing ${id} with ${user}`); 624 | let project = this.getProject(id); 625 | if (!project) { return; } 626 | 627 | project.linkedWith.filter(proj => (proj.owner.toLowerCase() == user.toLowerCase())).forEach(proj => { 628 | project.linkedWith.splice(project.linkedWith.indexOf(proj)); 629 | this.deleteScratchProjectEntry(proj.scratchId); 630 | // delete this.scratchprojects[proj.scratchId] 631 | // let projectPatch = scratchprojectsPath + path.sep + sanitize(proj.scratchId + ''); 632 | // if(fs.existsSync(projectPatch)) { 633 | // try{ fs.rmSync(projectPatch) } catch(e){console.error(e)} 634 | // } 635 | }); 636 | 637 | if (project.owner.toLowerCase() == user.toLowerCase()) { 638 | project.owner = project.sharedWith[0] ? project.sharedWith[0] : ''; 639 | } 640 | 641 | let userIndex = project.sharedWith.indexOf(user); 642 | if (userIndex != -1) { 643 | project.sharedWith.splice(userIndex, 1); 644 | } 645 | 646 | // delete the project file if no-one owns it 647 | if (project.onwer == '') { 648 | this.deleteProjectFile(project.id); 649 | } 650 | // TODO: Handle what-if their project is the inpoint? 651 | } 652 | 653 | deleteProjectFile(id) { 654 | console.log(`deleting 🚮 project file with id ${id}`); 655 | 656 | this.offloadProject(id); 657 | let projectPath = livescratchPath + path.sep + sanitize(id); 658 | if (fs.existsSync(projectPath)) { 659 | try { fs.rmSync(projectPath); } catch (e) { console.error('error when deleting project file after unsharing with everyone', e); } 660 | } 661 | } 662 | 663 | getScratchToLSProject(scratchId) { 664 | let blId = this.getScratchProjectEntry(scratchId)?.blId; 665 | if (!blId) { return null; } 666 | return this.getProject(blId); 667 | } 668 | 669 | getScratchProjectEntry(scratchId) { 670 | try { 671 | if (!scratchId) { return; } 672 | let scratchIdFilename = sanitize(scratchId + ''); 673 | let filename = scratchprojectsPath + path.sep + scratchIdFilename; 674 | if (!fs.existsSync(filename)) { return null; } 675 | let file = fs.readFileSync(filename); 676 | let entry = JSON.parse(file); 677 | return entry; 678 | } catch (e) { console.error(e); } 679 | } 680 | makeScratchProjectEntry(scratchId, owner, blId) { 681 | try { 682 | if (!scratchId) { return; } 683 | let scratchIdFilename = sanitize(scratchId + ''); 684 | let filename = scratchprojectsPath + path.sep + scratchIdFilename; 685 | let entry = { owner, blId }; 686 | let fileData = JSON.stringify(entry); 687 | ensureDirectoryExistence(filename); 688 | fs.writeFileSync(filename, fileData); 689 | } catch (e) { console.error(e); } 690 | } 691 | doesScratchProjectEntryExist(scratchId) { 692 | if (!scratchId) { return false; } 693 | let scratchIdFilename = sanitize(scratchId + ''); 694 | let filename = scratchprojectsPath + path.sep + scratchIdFilename; 695 | return fs.existsSync(filename); 696 | } 697 | deleteScratchProjectEntry(scratchId) { 698 | console.log(`DELETING scratch project entry ${scratchId}`); 699 | if (!scratchId) { return; } 700 | if (!this.doesScratchProjectEntryExist(scratchId)) { return; } 701 | let scratchIdFilename = sanitize(scratchId + ''); 702 | let filename = scratchprojectsPath + path.sep + scratchIdFilename; 703 | fs.rmSync(filename); 704 | } 705 | 706 | // if 'from' is null, defaults to 'Livescratch' 707 | broadcastMessageToAllActiveProjects(message, from) { 708 | Object.entries(this.livescratch).forEach(entry => { 709 | let id = entry[0]; 710 | let project = entry[1]; 711 | 712 | try { 713 | if (Object.keys(project.session.connectedClients).length > 0) { 714 | project.serverSendChat(message, from); 715 | } 716 | } catch (e) { console.error(e); } 717 | }); 718 | } 719 | 720 | getStats() { 721 | let set1 = new Set(); 722 | let set2 = new Set(); 723 | let aloneSet = new Set(); 724 | let sharedSet = new Set(); 725 | let collabingSet = new Set(); 726 | let stats = { 727 | active1HrCollabing:0, 728 | active2HrCollabing:0, 729 | active24HrCollabing:0, 730 | active1weekCollabing:0, 731 | active30dCollabing:0, 732 | active1HrRealtime:0, 733 | active24HrRealtime:0, 734 | active1weekRealtime:0, 735 | active30dRealtime:0, 736 | active1Hr:0, 737 | active24Hr:0, 738 | active1week:0, 739 | active30d:0, 740 | popup24hr:0, 741 | popup1week:0, 742 | popup1month:0, 743 | popupUnique24hr:0, 744 | popupUnique1week:0, 745 | totalActiveProjects: 0, 746 | totalProjectsMoreThan1Editor: 0, 747 | usersActiveCount: 0, 748 | usersActiveMoreThan1EditorCount: 0, 749 | monthlyProjects:0, 750 | monthlyScratchIds:0, 751 | collabingNow:0, 752 | sharedNow:0, 753 | aloneNow:0, 754 | usersActive: [], 755 | usersActiveMoreThan1Editor: [], 756 | projectsActiveSingleEditor:[], 757 | projectsActiveMoreThan1Editor:[], 758 | maxInOneProject: { 759 | id: 0, 760 | num: 0, 761 | }, 762 | }; 763 | Object.entries(this.livescratch).forEach(entry => { 764 | let id = entry[0]; 765 | let project = entry[1]; 766 | 767 | let connectedUsernames = project.session.getConnectedUsernames(); 768 | try { 769 | if(connectedUsernames.length==1) { 770 | stats.projectsActiveSingleEditor.push(project.scratchId); 771 | if(project.sharedWith?.length==0) {connectedUsernames.forEach(aloneSet.add, aloneSet);} 772 | if(project.sharedWith?.length>0) {connectedUsernames.forEach(sharedSet.add, sharedSet);} 773 | } 774 | if (connectedUsernames.length > 0) { 775 | stats.totalActiveProjects++; 776 | project.session.getConnectedUsernames().forEach(set1.add, set1); 777 | } 778 | if (connectedUsernames.length > 1) { 779 | stats.totalProjectsMoreThan1Editor++; 780 | connectedUsernames.forEach(collabingSet.add, collabingSet); 781 | stats.usersActiveMoreThan1Editor.push(connectedUsernames); 782 | stats.projectsActiveMoreThan1Editor.push(project.scratchId); 783 | } 784 | if (connectedUsernames.length > stats.maxInOneProject.num) { 785 | stats.maxInOneProject.num = Object.keys(project.session.connectedClients).length; 786 | stats.maxInOneProject.id = project.id; 787 | } 788 | } catch (e) { console.error(e); } 789 | }); 790 | stats.usersActive = Array.from(set1); 791 | let oldUsersActiveMoreThan1Editor = Array.from(collabingSet); 792 | stats.usersActiveCount = stats.usersActive.length; 793 | stats.usersActiveMoreThan1EditorCount = oldUsersActiveMoreThan1Editor.length; 794 | 795 | collabingSet.forEach(aloneSet.delete,aloneSet); 796 | collabingSet.forEach(sharedSet.delete,sharedSet); 797 | sharedSet.forEach(aloneSet.delete,aloneSet); 798 | 799 | stats.collabingNow = collabingSet.size; 800 | stats.sharedNow = sharedSet.size; 801 | stats.aloneNow = aloneSet.size; 802 | 803 | stats.active1HrCollabing = countRecentShared(1/24); 804 | stats.active2HrCollabing = countRecentShared(1/24*2); 805 | stats.active24HrCollabing = countRecentShared(1); 806 | stats.active1weekCollabing = countRecentShared(7); 807 | stats.active30dCollabing = countRecentShared(30); 808 | stats.active24HrRealtime = countRecentRealtime(1); 809 | stats.active2HrRealtime = countRecentRealtime(1/24*2); 810 | stats.active1weekRealtime = countRecentRealtime(7); 811 | stats.active30dRealtime = countRecentRealtime(30); 812 | stats.active1Hr = countRecent(1/24); 813 | stats.active1HrRealtime = countRecentRealtime(1/24); 814 | stats.active2Hr = countRecent(1/24*2); 815 | stats.active24Hr = countRecent(1); 816 | stats.active1week = countRecent(7); 817 | stats.active30d = countRecent(30); 818 | stats.popup24hr = countPopup(1); 819 | stats.popup1week = countPopup(7); 820 | stats.popup1month = countPopup(30); 821 | stats.popupUnique24hr = countUniquePopup(1); 822 | stats.popupUnique1week = countUniquePopup(7); 823 | stats.monthlyProjects = fs.readdirSync(livescratchPath).length; 824 | stats.monthlyScratchIds = fs.readdirSync(scratchprojectsPath).length; 825 | 826 | stats.auth = getAuthStats(); 827 | stats.auth.numWithCreds = numWithCreds; 828 | stats.auth.numWithoutCreds = numWithoutCreds; 829 | return stats; 830 | } 831 | 832 | canUserAccessProject(username,blId) { 833 | let project = this.getProject(blId); 834 | if(!project) {return true;} 835 | return project.isSharedWithCaseless(username); 836 | } 837 | } -------------------------------------------------------------------------------- /backend/utils/userManager.js: -------------------------------------------------------------------------------- 1 | // user: 2 | // friends LIST of STRING 3 | // projects owned by user LIST of 4 | // projects shared to user LIST 5 | // > livescratch id 6 | // > from user 7 | // scratch id (pk- "primary key") 8 | // 9 | 10 | import sanitize from 'sanitize-filename'; 11 | import { saveMapToFolder, usersPath } from './fileStorage.js'; 12 | import path from 'path'; 13 | import fs from 'fs'; 14 | 15 | const OFFLOAD_TIMEOUT_MILLIS = 30 * 1000; 16 | 17 | export default class UserManager { 18 | 19 | // removed since dynamic reloading/offloading 20 | // static fromJSON(json) { 21 | // let thing = new UserManager() 22 | // thing.users = json.users 23 | // return thing 24 | // } 25 | 26 | users = {}; 27 | 28 | verify(username, token) { 29 | return !!(getUser(username)?.token == token); // 🟢 30 | } 31 | 32 | befriend(base, to) { 33 | console.log(base + ' friending ' + to); 34 | this.getUser(base)?.friends.push(to?.toLowerCase()); // 🚨 35 | } 36 | unbefriend(base, take) { 37 | console.log(base + ' unfriending ' + take); 38 | take = take?.toLowerCase(); 39 | this.getUser(base)?.friends.splice(this.getUser(base)?.friends.indexOf(take), 1); // 🚨 40 | } 41 | 42 | userExists(username) { 43 | this.reloadUser(username); 44 | return (username?.toLowerCase?.() in this.users); 45 | } 46 | 47 | getUser(username) { 48 | 49 | // clear previous timeout 50 | clearTimeout(this.offloadTimeoutIds[username]); 51 | delete this.offloadTimeoutIds[username]; 52 | // set new timeout 53 | let timeout = setTimeout(() => { this.offloadUser(username); }, OFFLOAD_TIMEOUT_MILLIS); 54 | this.offloadTimeoutIds[username] = timeout; 55 | 56 | 57 | this.reloadUser(username); 58 | if (!(username?.toLowerCase() in this.users)) { 59 | this.addUser(username); 60 | } 61 | return this.users[username.toLowerCase()]; // 🟢 62 | } 63 | 64 | addUser(username) { 65 | this.reloadUser(username); 66 | if (!(username?.toLowerCase() in this.users)) { 67 | this.users[username.toLowerCase()] = { username, friends: [], token: this.token(), sharedTo: {}, myProjects: [], verified:false, privateMe: false }; // 🚨 68 | } 69 | return this.getUser(username); 70 | } 71 | 72 | offloadTimeoutIds = {}; 73 | 74 | reloadUser(username) { 75 | if (!username?.toLowerCase) { console.error(`username is not string ${username}`); console.trace(); return; } // username is not a string 76 | username = username.toLowerCase(); 77 | 78 | if (!(username in this.users)) { 79 | // console.log(`reloading user ${username}`) 80 | 81 | let usernameFile = sanitize(username + ''); 82 | if (!usernameFile) { return; } // dont do anything if username doesnt exist 83 | 84 | let filename = usersPath + path.sep + usernameFile; 85 | 86 | if (!fs.existsSync(filename)) { return; } 87 | 88 | let json = fs.readFileSync(filename); 89 | let user = JSON.parse(json); 90 | this.users[username] = user; 91 | 92 | 93 | } 94 | } 95 | offloadUser(username) { 96 | // console.log(`offloading user ${username}`) 97 | if (!username?.toLowerCase) { console.error(`username is not string ${username}`); console.trace(); return; } // username is not a string 98 | username = username.toLowerCase(); 99 | if (!(username in this.users)) { return; } 100 | let usersSave = {}; 101 | usersSave[username] = this.users[username]; // get user object to save 102 | delete this.users[username]; // delete from ram 103 | saveMapToFolder(usersSave, usersPath); // write file 104 | } 105 | 106 | newProject(owner, blId) { 107 | console.log(`usrMngr: adding new project ${blId} owned by ${owner}`); 108 | if (this.getUser(owner).myProjects.indexOf(blId) != -1) { return; } 109 | this.getUser(owner).myProjects.push(blId); 110 | } 111 | 112 | share(username, blId, from) { 113 | from = from?.toLowerCase(); 114 | console.log(`usrMngr: sharing ${blId} with ${username} from ${from}`); 115 | let map = this.getUser(username)?.sharedTo; 116 | if (!map) { return; } 117 | if (blId in map) { return; } 118 | map[blId] = { from, id: blId }; 119 | } 120 | unShare(username, blId) { 121 | username = username?.toLowerCase(); 122 | console.log(`usrMngr: unsharing ${blId} with ${username}`); 123 | let map = this.getUser(username)?.sharedTo; 124 | if (!map) { return; } 125 | delete map[blId]; 126 | 127 | let ownedIndex = this.getUser(username)?.myProjects.indexOf(blId); 128 | if (ownedIndex != -1) { 129 | this.getUser(username)?.myProjects.splice(ownedIndex, 1); 130 | } 131 | 132 | 133 | 134 | } 135 | getSharedObjects(username) { 136 | return Object.values(this.getUser(username)?.sharedTo); 137 | } 138 | getShared(username) { 139 | let user = this.getUser(username); 140 | let objs = this.getSharedObjects(username); 141 | if (!objs) { return []; } 142 | return objs.filter((proj) => (user.friends.indexOf(proj.from?.toLowerCase()) != -1)).map((proj) => (proj.id)); 143 | } 144 | getAllProjects(username) { 145 | return this.getUser(username).myProjects.concat(this.getShared(username)); 146 | } 147 | 148 | rand() { 149 | return Math.random().toString(36).substring(2); // remove `0.` 150 | }; 151 | 152 | token() { 153 | return this.rand() + this.rand(); // to make it longer 154 | }; 155 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | files: ['**/*.js'], 4 | languageOptions: { 5 | ecmaVersion: 2024, 6 | sourceType: 'module', 7 | globals: { 8 | browser: true, 9 | node: true, 10 | es6: true, 11 | }, 12 | }, 13 | rules: { 14 | 'indent': ['error', 4], 15 | 'quotes': ['error', 'single'], 16 | 'semi': ['error', 'always'], 17 | 'comma-dangle': ['error', 'always-multiline'], 18 | 'no-console': 'off', 19 | }, 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /extension/UI_modal/index.js: -------------------------------------------------------------------------------- 1 | class livescratchModal { 2 | constructor(content) { 3 | this.content = content; 4 | 5 | let nunitoLink = document.createElement('link'); 6 | nunitoLink.rel="stylesheet"; 7 | nunitoLink.href="https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap"; 8 | document.head.appendChild(nunitoLink); 9 | 10 | this.popup(); 11 | } 12 | 13 | popup() { 14 | let modalOverlay = document.createElement("span"); 15 | modalOverlay.id = 'ls-modal-container'; 16 | 17 | const logoUrl = document.querySelector('.livescratch-ext-2').dataset.logoUrl; 18 | 19 | modalOverlay.innerHTML = ` 20 |
21 |
22 | 23 | LiveScratch 24 |
25 |
26 | ${this.content} 27 |
28 |
29 | ` 30 | 31 | modalOverlay.onclick = (event) => { 32 | const modal = modalOverlay.querySelector("#ls-modal"); 33 | 34 | if (event.target === modal || modal.contains(event.target)) { 35 | return; 36 | } 37 | 38 | this.close(); 39 | } 40 | 41 | document.body.appendChild(modalOverlay); 42 | 43 | modalOverlay.offsetHeight; 44 | 45 | modalOverlay.style.opacity = '1'; 46 | } 47 | 48 | close() { 49 | const modalOverlay = document.querySelector("#ls-modal-container"); 50 | if (modalOverlay) { 51 | modalOverlay.style.opacity = '0'; // Fade out 52 | modalOverlay.addEventListener('transitionend', () => { 53 | modalOverlay.remove(); // Remove after transition ends 54 | }); 55 | } 56 | } 57 | } 58 | //new livescratchModal("hi"); -------------------------------------------------------------------------------- /extension/UI_modal/style.css: -------------------------------------------------------------------------------- 1 | #ls-modal-container { 2 | position: fixed; 3 | width: 100%; 4 | height: 100%; 5 | opacity: 0; /* Initial state */ 6 | transition: opacity 0.2s ease; /* Smooth transition */ 7 | backdrop-filter: blur(20px); 8 | z-index: 2000; 9 | top: 0; 10 | left: 0; 11 | 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | 17 | #ls-modal-container.show { 18 | opacity: 1; /* Fully visible */ 19 | } 20 | 21 | #ls-modal-header-logo { 22 | height: 40px; 23 | } 24 | 25 | #ls-modal { 26 | border: 1px solid #ffffff0d; 27 | box-shadow: 1px 1px 10px #0000001c; 28 | font-family: "Nunito", serif; 29 | display: flex; 30 | flex-direction: column; 31 | background-color: #fff; 32 | border-radius: 8px; 33 | width: max-content; 34 | overflow: clip; 35 | } 36 | 37 | #ls-modal-header { 38 | padding: 12px 15px; 39 | display: flex; 40 | flex-direction: row; 41 | align-items: center; 42 | gap: 8px; 43 | font-size: 25px; 44 | font-weight: bold; 45 | color: #fff; 46 | background: linear-gradient(100deg, #4C97FF -2.26%, #59C059 135.83%); 47 | } 48 | 49 | #ls-modal-content { 50 | display: flex; 51 | flex-direction: column; 52 | padding: 17px 25px; 53 | } -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | // monkey-patch before importing socket.io.js 2 | self.addEventListener = (function (original) { 3 | return function (type, listener, options) { 4 | if (type === 'beforeunload') { 5 | console.warn('Ignoring \'beforeunload\' listener in service worker context.'); 6 | return; 7 | } 8 | return original.call(self, type, listener, options); 9 | }; 10 | })(self.addEventListener); 11 | 12 | importScripts('background/socket.io.js', 'background/livescratchProject.js', 'background/auth.js'); 13 | 14 | const getStorageValue = (key) => { 15 | return new Promise((resolve, reject) => { 16 | chrome.storage.local.get(key, (result) => { 17 | if (chrome.runtime.lastError) { 18 | reject(chrome.runtime.lastError); 19 | } else { 20 | resolve(result[key]); 21 | } 22 | }); 23 | }); 24 | }; 25 | 26 | 27 | const defaultApiUrl = 'https://blserver.waakul.com' 28 | const getApiUrl = async () => { 29 | const customServer = await getStorageValue('custom-server'); 30 | 31 | if (customServer) { 32 | const serverUrl = await getStorageValue('server-url'); 33 | return serverUrl || defaultApiUrl; 34 | } 35 | 36 | return defaultApiUrl; 37 | }; 38 | 39 | let apiUrl; 40 | 41 | const loadUrl = () => { 42 | return new Promise(async (resolve, reject) => { 43 | try { 44 | apiUrl = await getApiUrl(); 45 | resolve(); // Ensure this is final 46 | } catch (error) { 47 | console.error('Failed to get the API URL:', error); 48 | apiUrl = 'https://livescratchapi.waakul.com'; 49 | reject(error); // Only reject on actual failure 50 | } 51 | }); 52 | }; 53 | 54 | loadUrl().then(()=>{ 55 | backgroundScript(); 56 | }) 57 | .catch((error)=>{ 58 | console.error('Error loading server URL', error); 59 | }); 60 | 61 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 62 | if (message.meta === 'getAPI-URL') { 63 | sendResponse({apiURL: apiUrl}); 64 | } 65 | 66 | return true; 67 | }); 68 | 69 | /// DECS 70 | let uname = '*'; 71 | let upk = undefined; 72 | 73 | chrome.runtime.onInstalled.addListener(async (details) => { 74 | let { apiUpdateReload } = await chrome.storage.local.get('apiUpdateReload'); // Destructure the result 75 | apiUpdateReload = await { apiUpdateReload }['apiUpdateReload']; 76 | console.log(apiUpdateReload); 77 | 78 | if (!!apiUpdateReload) { 79 | await chrome.storage.local.set({ 'apiUpdateReload': false }); 80 | return; 81 | } 82 | 83 | if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { 84 | chrome.tabs.create({ url: 'https://ko-fi.com/waakul' }); 85 | chrome.tabs.create({ url: 'https://livescratch.waakul.com' }); 86 | } else if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) { 87 | chrome.tabs.create({ url: 'https://livescratch.waakul.com' /* 'https://livescratch.waakul.com/new-release' */ }); 88 | } 89 | }); 90 | 91 | 92 | const LIVESCRATCH = {}; 93 | async function backgroundScript() { 94 | 95 | // user info 96 | // let username = 'ilhp10' 97 | 98 | // let apiUrl = 'http://127.0.0.1:4000' 99 | 100 | ////////// ACTIVE PROJECTS DATABASE ////////// 101 | // blId -> [ports...] 102 | let livescratchTabs = {}; 103 | // blId -> LivescratchProject 104 | let projects = {}; 105 | // portName -> blId 106 | let portIds = {}; 107 | 108 | let lastPortId = 0; 109 | let ports = []; 110 | 111 | let newProjects = {}; // tabId (or 'newtab') -> blId 112 | let tabCallbacks = {}; // tabId -> callback function 113 | 114 | function getProjectId(url) { 115 | if (projectsPageTester.test(url)) { 116 | let id = new URL(url).pathname.split('/')[2]; 117 | // dont redirect if is not /projects/id/... 118 | if (isNaN(parseFloat(id))) { 119 | return null; 120 | } else { 121 | return id; 122 | } 123 | } else { 124 | return null; 125 | } 126 | } 127 | 128 | async function handleNewProject(tab) { 129 | let id = getProjectId(tab.url); 130 | if (!!id && tab.id in newProjects) { 131 | let blId = newProjects[tab.id]; 132 | delete newProjects[tab.id]; 133 | fetch(`${apiUrl}/linkScratch/${id}/${blId}/${uname}`, { 134 | method: 'PUT', 135 | headers: { authorization: currentBlToken }, 136 | }); // link scratch project with api 137 | tabCallbacks[tab.id]({ meta: 'initLivescratch', blId }); // init livescratch in project tab 138 | } 139 | } 140 | 141 | const newProjectPage = 'https://scratch.mit.edu/create'; 142 | async function prepRedirect(tab) { 143 | if (uname == '*') { return false; }importScripts('background/socket.io.js', 'background/livescratchProject.js', 'background/auth.js'); 144 | let id = getProjectId(tab.url); 145 | 146 | 147 | // dont redirect if is not /projects/id/... 148 | if (!id) { return false; } 149 | let info = await (await fetch(apiUrl + `/userRedirect/${id}/${uname}`, { headers: { authorization: currentBlToken } })).json(); 150 | // dont redirect if scratch id is not associated with ls project 151 | if (info.goto == 'none') { return false; } 152 | // dont redirect if already on project 153 | if (info.goto == id) { return false; } 154 | 155 | if (info.goto == 'new') { 156 | //register callbacks and redirect 157 | newProjects[tab.id] = info.lsId; //TODO: send this with api 158 | return newProjectPage; 159 | } else { 160 | if (tab.url.endsWith('editor') || tab.url.endsWith('editor/')) { 161 | return `https://scratch.mit.edu/projects/${info.goto}/editor`; 162 | } else { 163 | return `https://scratch.mit.edu/projects/${info.goto}`; 164 | } 165 | } 166 | } 167 | 168 | function playChange(blId, msg, optPort) { 169 | // record change 170 | //projects[blId]?.recordChange(msg) 171 | 172 | // send to local clients 173 | if (!!optPort) { 174 | livescratchTabs[blId]?.forEach((p => { try { if (p != optPort) { p.postMessage(msg); } } catch (e) { console.error(e); } })); 175 | } else { 176 | livescratchTabs[blId]?.forEach(p => { try { p.postMessage(msg); } catch (e) { console.log(e); } }); 177 | } 178 | } 179 | 180 | //////// INIT SOCKET CONNECTION /////// 181 | // ['websocket', 'xhr-polling', 'polling', 'htmlfile', 'flashsocket'] 182 | const URLApiUrl = new URL(apiUrl); 183 | const URLApiDomain = URLApiUrl.origin; 184 | const URLApiPath = [''].concat(URLApiUrl.pathname.split('/').filter(Boolean)).join('/'); 185 | const socket = io.connect(URLApiDomain, { path: `${URLApiPath}/socket.io/`, jsonp: false, transports: ['websocket', 'xhr-polling', 'polling', 'htmlfile', 'flashsocket'] }); 186 | LIVESCRATCH.socket = socket; 187 | // const socket = io.connect(apiUrl,{jsonp:false,transports:['websocket']}) 188 | // socket.on("connect_error", () => { socket.io.opts.transports = ["websocket"];}); 189 | console.log('connecting'); 190 | socket.on('connect', async () => { 191 | console.log('connected with id: ', socket.id); 192 | ports.forEach(port => port.postMessage({ meta: 'resync' })); 193 | let blIds = Object.keys(livescratchTabs); 194 | if (blIds.length != 0) { socket.send({ type: 'joinSessions', username: await makeSureUsernameExists(), pk: upk, ids: blIds, token: currentBlToken }); } 195 | }); 196 | socket.on('disconnect', () => { 197 | setTimeout( 198 | () => { 199 | if (ports.length != 0) { 200 | socket.connect(); 201 | } 202 | }, 600); 203 | }); 204 | socket.on('connect_error', () => { 205 | setTimeout(() => { 206 | socket.connect(); 207 | }, 1000); 208 | }); // copied from https://socket.io/docs/v3/client-socket-instance/ 209 | socket.on('message', (data) => { 210 | console.log('message', data); 211 | if (data.type == 'projectChange') { 212 | if (data.version) { projects[data.blId]?.setVersion(data.version - 1); } 213 | data.msg.version = data.version; 214 | playChange(data.blId, data.msg); 215 | } else if (data.type == 'yourVersion') { 216 | projects[data.blId]?.setVersion(data.version); 217 | } 218 | }); 219 | 220 | 221 | uname = (await chrome.storage.local.get(['uname'])).uname; // FIRST DEC 222 | upk = (await chrome.storage.local.get(['upk'])).upk; // FIRST DEC 223 | uname = uname ? uname : '*'; 224 | upk = upk ? upk : undefined; 225 | 226 | 227 | let lastUnameRefresh = null; 228 | let signedin = true; 229 | async function refreshUsername(force) { 230 | // if(!force && uname!='*' && Date.now() - lastUnameRefresh < 1000 * 10) {return uname} // limit to refreshing once every 10 seconds 231 | lastUnameRefresh = Date.now(); 232 | res = await fetch('https://scratch.mit.edu/session/?blreferer', { 233 | headers: { 234 | 'X-Requested-With': 'XMLHttpRequest', 235 | }, 236 | }); 237 | let json = await res.json(); 238 | if (!json.user) { 239 | signedin = false; 240 | return uname; 241 | } 242 | signedin = true; 243 | uname = json.user.username; 244 | upk = json.user.id; 245 | chrome.storage.local.set({ uname, upk }); 246 | await getCurrentBLTokenAfterUsernameRefresh?.(); 247 | await testVerification(); 248 | 249 | return uname; 250 | } 251 | LIVESCRATCH.refreshUsername = refreshUsername; 252 | 253 | async function testVerification() { 254 | try { 255 | let json = await (await fetch(`${apiUrl}/verify/test?username=${uname}`, { headers: { authorization: currentBlToken } })).json(); 256 | if (!json.verified) { 257 | storeLivescratchToken(uname, null, true); 258 | } 259 | 260 | } catch (e) { console.error(e); } 261 | } 262 | 263 | async function makeSureUsernameExists() { 264 | if (uname == '*') { 265 | return refreshUsername(); 266 | } else { 267 | return uname; 268 | } 269 | } 270 | refreshUsername(); 271 | 272 | // Listen for Project load 273 | let projectsPageTester = new RegExp('https://scratch.mit.edu/projects/*.'); 274 | chrome.tabs.onUpdated.addListener(async function (tabId, changeInfo, tab) { 275 | if (changeInfo.url?.startsWith('https://scratch.mit.edu/')) { refreshUsername(true); } 276 | if (changeInfo.url) { 277 | await makeSureUsernameExists(); 278 | 279 | console.log('tab location updated', changeInfo, tab); 280 | 281 | let newUrl = await prepRedirect(tab); 282 | if (newUrl) { 283 | console.log('redirecting tab to: ' + newUrl, tab); 284 | chrome.tabs.update(tab.id, { url: newUrl }); 285 | } else { 286 | handleNewProject(tab); 287 | } 288 | } 289 | }, 290 | ); 291 | 292 | // from chrome runtime documentation 293 | async function getCurrentTab() { 294 | let queryOptions = { active: true, lastFocusedWindow: true }; 295 | // `tab` will either be a `tabs.Tab` instance or `undefined`. 296 | let [tab] = await chrome.tabs.query(queryOptions); 297 | return tab; 298 | } 299 | 300 | let userExistsStore = {}; 301 | async function testUserExists(username) { 302 | username = username.toLowerCase(); 303 | if (username in userExistsStore) { 304 | console.log(userExistsStore); 305 | return userExistsStore[username]; 306 | } else { 307 | let res = await fetch(`${apiUrl}/userExists/${username}`); 308 | let answer = await res.json(); 309 | console.log(answer); 310 | userExistsStore[username] = answer; 311 | 312 | return answer; 313 | } 314 | } 315 | 316 | 317 | 318 | // Connections to scratch editor instances 319 | chrome.runtime.onConnectExternal.addListener(function (port) { 320 | if (!socket.connected) { socket.connect(); } 321 | 322 | port.name = ++lastPortId; 323 | ports.push(port); 324 | 325 | let blId = ''; 326 | // console.assert(port.name === "knockknock"); 327 | port.onMessage.addListener(async function (msg) { 328 | console.log('isConnected', socket.connected); 329 | if (!socket.connected) { 330 | // messageOnConnect.push(msg) 331 | socket.connect(); 332 | } 333 | 334 | console.log(msg); 335 | if (msg.meta == 'blockly.event' || msg.meta == 'sprite.proxy' || msg.meta == 'vm.blockListen' || msg.meta == 'vm.shareBlocks' || msg.meta == 'vm.replaceBlocks' || msg.meta == 'vm.updateBitmap' || msg.meta == 'vm.updateSvg' || msg.meta == 'version++') { 336 | let blIdd = portIds[port.name]; 337 | 338 | msg.user = uname; 339 | playChange(blIdd, msg, port); 340 | 341 | // send to websocket 342 | socket.send({ type: 'projectChange', msg, blId: blIdd, token: currentBlToken, username: uname }, (res) => { 343 | if (!!res) { 344 | port.postMessage({ meta: 'yourVersion', version: res }); 345 | } 346 | }); 347 | } else if (msg.meta == 'myId') { 348 | blId = msg.id; 349 | // record websocket id 350 | if (!(msg.id in livescratchTabs)) { 351 | livescratchTabs[msg.id] = []; 352 | } 353 | if (port.name in portIds) { } 354 | else { 355 | livescratchTabs[msg.id].push(port); 356 | portIds[port.name] = msg.id; 357 | } 358 | 359 | // create project object 360 | if (!(msg.id in projects)) { 361 | projects[msg.id] = new LivescratchProject(); 362 | } 363 | } else if (msg.meta == 'joinSession') { 364 | await makeSureUsernameExists(); 365 | socket.send({ type: 'joinSession', id: portIds[port.name], username: await makeSureUsernameExists(), pk: upk, token: currentBlToken }); 366 | } else if (msg.meta == 'setTitle') { 367 | playChange(blId, msg, port); 368 | // send to websocket 369 | socket.send({ type: 'setTitle', blId, msg, token: currentBlToken, username: uname }); 370 | } else if (msg.meta == 'chat') { 371 | playChange(blId, msg, port); 372 | // send to websocket 373 | socket.send({ type: 'chat', blId, msg, token: currentBlToken }); 374 | } else if (msg.meta == 'chatnotif') { 375 | let tab = port.sender.tab; 376 | let notifs = (await chrome.storage.local.get(['notifs'])).notifs ?? false; 377 | console.log('notifs', notifs); 378 | if (notifs) { 379 | 380 | chrome.notifications.create(null, 381 | { 382 | type: 'basic', 383 | title: 'Livescratch Chat', 384 | contextMessage: `${msg.sender} says in '${msg.project}':`, 385 | message: msg.text, 386 | // iconUrl:chrome.runtime.getURL('img/livescratchfullres.png'), 387 | // iconUrl:msg.avatar, 388 | // iconUrl:'https://assets.scratch.mit.edu/981e22b1b61cad530d91ea2cfd5ccec7.svg', 389 | // iconUrl:'https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Red_Circle%28small%29.svg/2048px-Red_Circle%28small%29.svg.png' 390 | iconUrl: 'img/livescratchfullres.png', 391 | // isClickable:true, 392 | }, 393 | (notif) => { 394 | console.log('😱😱😱😱😱😱😱😱😱 DING DING NOTIFICATION', notif); 395 | notificationsDb[notif] = { tab: tab.id, window: tab.windowId }; 396 | console.error(chrome.runtime.lastError); 397 | }, 398 | ); 399 | 400 | if (!notifListenerAdded) { 401 | chrome.notifications.onClicked.addListener(notif => { 402 | chrome.tabs.update(notificationsDb[notif]?.tab, { selected: true }); 403 | chrome.windows.update(notificationsDb[notif]?.window, { focused: true }); 404 | }); 405 | notifListenerAdded = true; 406 | } 407 | } 408 | // if(getCurrentTab()?.id!=tab?.id) { 409 | // } 410 | 411 | } else { 412 | msg.blId = blId ?? msg.blId; 413 | msg.token = currentBlToken; 414 | socket.send(msg); 415 | } 416 | 417 | }); 418 | port.onDisconnect.addListener((p) => { 419 | console.log('port disconnected', p); 420 | ports.splice(ports.indexOf(p), 1); 421 | let livescratchId = portIds[p.name]; 422 | let list = livescratchTabs[livescratchId]; 423 | livescratchTabs[livescratchId].splice(list.indexOf(p), 1); 424 | delete portIds[p.name]; 425 | setTimeout(() => { 426 | if (livescratchTabs[livescratchId].length == 0) { socket.send({ type: 'leaveSession', id: livescratchId }); } 427 | if (ports.length == 0) { socket.disconnect(); } // Todo: handle disconnecting and reconnecting backend socket 428 | }, 5000); // leave socket stuff if page doesnt reconnect in 5 seconds 429 | }); 430 | }); 431 | var notificationsDb = {}; 432 | var notifListenerAdded = false; 433 | 434 | // Proxy project update messages 435 | chrome.runtime.onMessageExternal.addListener( 436 | function (request, sender, sendResponse) { 437 | (async () => { 438 | console.log('external message:', request); 439 | if (request.meta == 'getBlId') { 440 | if (!request.scratchId || request.scratchId == '.') { return ''; } 441 | sendResponse((await (await fetch(`${apiUrl}/lsId/${request.scratchId}/${uname}`, { headers: { authorization: currentBlToken } })).text()).replaceAll('"', '')); 442 | // } else if(request.meta =='getInpoint') { 443 | // sendResponse(await (await fetch(`${apiUrl}/projectInpoint/${request.blId}`)).json()) 444 | } else if (request.meta == 'getJson') { 445 | try { 446 | sendResponse(await (await fetch(`${apiUrl}/projectJSON/${request.blId}?username=${uname}`, { headers: { authorization: currentBlToken } })).json()); 447 | } catch (e) { sendResponse({ err: 'livescratch id does not exist' }); } 448 | } else if (request.meta == 'getChanges') { 449 | sendResponse(await (await fetch(`${apiUrl}/changesSince/${request.blId}/${request.version}`, { headers: { authorization: currentBlToken, uname } })).json()); 450 | } else if (request.meta == 'getUsername') { 451 | sendResponse(uname); 452 | } else if (request.meta == 'getUsernamePlus') { 453 | console.log('sending response'); 454 | console.log({ uname, signedin, currentBlToken, apiUrl}); 455 | sendResponse({ uname, signedin, currentBlToken, apiUrl}); 456 | } else if (request.meta == 'callback') { 457 | tabCallbacks[sender.tab.id] = sendResponse; 458 | } else if (request.meta == 'projectSaved') { 459 | // {meta:'projectSaved',blId,scratchId,version:blVersion} 460 | fetch(`${apiUrl}/projectSaved/${request.scratchId}/${request.version}`, { method: 'POST', headers: { authorization: currentBlToken } }); 461 | } else if (request.meta == 'projectSavedJSON') { 462 | // {meta:'projectSaved',blId,scratchId,version:blVersion} 463 | fetch(`${apiUrl}/projectSavedJSON/${request.blId}/${request.version}`, { method: 'POST', body: request.json, headers: { 'Content-Type': 'application/json', authorization: currentBlToken, uname } }); 464 | } else if (request.meta == 'myStuff') { 465 | sendResponse(await (await fetch(`${apiUrl}/userProjectsScratch/${await makeSureUsernameExists()}`, { headers: { authorization: currentBlToken } })).json()); 466 | } else if (request.meta == 'create') { 467 | // sendResponse(await(await fetch(`${apiUrl}/newProject/${request.scratchId}/${await refreshUsername()}?title=${encodeURIComponent(request.title)}`)).json()) 468 | sendResponse(await (await fetch(`${apiUrl}/newProject/${request.scratchId}/${await refreshUsername()}?title=${encodeURIComponent(request.title)}`, 469 | { 470 | method: 'POST', 471 | body: request.json, 472 | headers: { 'Content-Type': 'application/json', authorization: currentBlToken }, 473 | }).then(res => res.json()).catch(e => ({ err: e.toString() })))); 474 | } else if (request.meta == 'shareWith') { 475 | let response = await fetch(`${apiUrl}/share/${request.id}/${request.username}/${uname}?pk=${request.pk}`, { 476 | method: 'PUT', 477 | headers: { authorization: currentBlToken }, 478 | }); 479 | let statusCode = await response.status; 480 | sendResponse(statusCode); 481 | } else if (request.meta == 'unshareWith') { 482 | fetch(`${apiUrl}/unshare/${request.id}/${request.user}`, { 483 | method: 'PUT', 484 | headers: { authorization: currentBlToken, uname }, 485 | }); 486 | } else if (request.meta == 'getShared') { 487 | sendResponse(await (await fetch(`${apiUrl}/share/${request.id}`, { headers: { authorization: currentBlToken, uname } })).json()); 488 | } else if (request.meta == 'getTitle') { 489 | sendResponse((await (await fetch(`${apiUrl}/projectTitle/${request.blId}`, { headers: { authorization: currentBlToken, uname } })).json()).title); 490 | } else if (request.meta == 'leaveScratchId') { 491 | fetch(`${apiUrl}/leaveScratchId/${request.scratchId}/${await refreshUsername()}`, { 492 | method: 'PUT', 493 | headers: { authorization: currentBlToken }, 494 | }); 495 | } else if (request.meta == 'leaveLSId') { 496 | fetch(`${apiUrl}/leaveLSId/${request.blId}/${await refreshUsername()}`, { 497 | method: 'PUT', 498 | headers: { authorization: currentBlToken }, 499 | }); 500 | } else if (request.meta == 'getActive') { 501 | sendResponse(await (await fetch(`${apiUrl}/active/${request.id}`, { headers: { authorization: currentBlToken, uname } })).json()); 502 | } else if (request.meta == 'getUrl') { 503 | sendResponse(await chrome.runtime.getURL(request.for)); 504 | } else if (request.meta == 'isPingEnabled') { 505 | sendResponse((await chrome.storage.local.get(['ping'])).ping); 506 | } else if (request.meta == 'userExists') { 507 | sendResponse(await testUserExists(request.username)); 508 | } else if (request.meta == 'badges?') { 509 | sendResponse({badges:(await chrome.storage.local.get(['badges'])).badges}); 510 | } 511 | })(); 512 | return true; 513 | }); 514 | 515 | 516 | chrome.runtime.onMessage.addListener(async function (request, sender, sendResponse) { 517 | if (request.meta == 'getUsername') { 518 | sendResponse(uname); 519 | } else if (request.meta == 'getUsernamePlus') { 520 | sendResponse({ uname, signedin, currentBlToken, apiUrl}); 521 | refreshUsername(); 522 | } 523 | }); 524 | } 525 | -------------------------------------------------------------------------------- /extension/background/auth.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | let currentBlToken = null; 4 | async function getCurrentBlToken() { 5 | let username = await LIVESCRATCH.refreshUsername(); 6 | let blToken = await getLivescratchToken(username); 7 | currentBlToken = blToken; 8 | return blToken; 9 | } 10 | async function getCurrentBLTokenAfterUsernameRefresh() { 11 | let username = uname; 12 | let blToken = await getLivescratchToken(username); 13 | currentBlToken = blToken; 14 | return blToken; 15 | } 16 | 17 | async function getLivescratchToken(username) { 18 | username = username?.toLowerCase?.(); 19 | if(!username) {return null;} 20 | let key = `blToken.${username}`; 21 | let token = (await chrome.storage.local.get([key]))[key]; 22 | 23 | return token; 24 | } 25 | 26 | async function recordVerifyError(message) { 27 | if(!message) {message = `undefined, ${await getVerifyError()}`;} 28 | if(!message) {message = 'unspecificed error';} 29 | if(message instanceof Error) {message = `${message.stack}`;} 30 | console.log('recodring error',message); 31 | chrome.storage.local.set({verifyError:message}); 32 | fetch(`${apiUrl}/verify/recordError`,{ 33 | method:'post', 34 | body:JSON.stringify({msg:message}), 35 | headers:{uname,'Content-Type': 'application/json'}, 36 | }); 37 | } 38 | async function getVerifyError() { 39 | return (await chrome.storage.local.get('verifyError')).verifyError; 40 | } 41 | 42 | function clearCurrentBlToken() { 43 | storeLivescratchToken(uname,null); 44 | } 45 | 46 | let verifying = false; 47 | let endVerifyCallbacks = []; 48 | let startVerifyCallbacks = []; 49 | function startVerifying() { 50 | verifying = true; 51 | startVerifyCallbacks.forEach(func=>func?.()); 52 | } 53 | function endVerifying(success) { 54 | verifying = false; 55 | endVerifyCallbacks.forEach(func=>func?.(success)); 56 | } 57 | 58 | chrome.runtime.onInstalled.addListener((details)=>{ 59 | 60 | chrome.storage.local.set({dontShowVerifyError:false}); 61 | 62 | if(details.reason === chrome.runtime.OnInstalledReason.INSTALL) { 63 | } else if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) { 64 | } 65 | }); 66 | 67 | 68 | let clientCode = null; 69 | const VERIFY_RATELIMIT = 1000 * 60 * 10; // wait ten minutes before trying to update again 70 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 71 | ; 72 | (async ()=>{ 73 | if (request.meta == 'verify?') { 74 | if(uname=='*') {return;} // dont verify if user is logged out 75 | console.log('verify recieved'); 76 | let token = await getCurrentBlToken(); 77 | let freepassExpired = false; 78 | if(String(token).startsWith('freepass')) { 79 | passDate =parseInt(token.split(' ')[1]); 80 | if(Date.now() - passDate > VERIFY_RATELIMIT) {freepassExpired = true;} 81 | } 82 | if(!token || freepassExpired) { 83 | 84 | try{ 85 | clientCode = Math.random().toString(); 86 | console.log('client code',clientCode); 87 | 88 | let verifyResponse; 89 | try{ 90 | verifyResponse = await (await fetch(`${apiUrl}/verify/start?code=${clientCode}`,{headers:{uname}})).json(); 91 | } catch (e) { 92 | console.error('verify init network request error'); 93 | chrome.storage.local.set({verifyServerConnErr:true}); 94 | sendResponse(); // empty resposne means dont do it; 95 | return; 96 | } 97 | chrome.storage.local.set({verifyServerConnErr:false}); 98 | 99 | console.log('verify response',verifyResponse); 100 | 101 | let code = verifyResponse.code; 102 | let project = verifyResponse.project; 103 | 104 | startVerifying(); 105 | sendResponse({code,project}); 106 | } catch (e) {console.error(e); endVerifying(false); recordVerifyError(e);} 107 | } else {sendResponse(false);} 108 | } else if (request.meta == 'setCloud') { 109 | console.log('setCloud',request.res); 110 | let res = request.res; 111 | if(res===true) {res={ok:true};} 112 | try{ 113 | if(!res?.ok) { 114 | endVerifying(false); 115 | recordVerifyError(res?.err); 116 | } 117 | let tokenResponse = await (await fetch(`${apiUrl}/verify/userToken?code=${clientCode}`,{headers:{uname}})).json(); 118 | 119 | console.log('tokenResponse',tokenResponse); 120 | if(tokenResponse.freepass) { 121 | storeLivescratchToken(uname,`freepass ${Date.now()}`,true); 122 | endVerifying(true); 123 | } else if(tokenResponse.err) { 124 | recordVerifyError(tokenResponse.err); 125 | endVerifying(false); 126 | } else { 127 | storeLivescratchToken(tokenResponse.username,tokenResponse.token,true); 128 | endVerifying(true); 129 | } 130 | } catch(e) { 131 | recordVerifyError(e); 132 | endVerifying(false); 133 | } 134 | } else if (request.meta == 'clearCrntToken') { 135 | await clearCurrentBlToken(); 136 | sendResponse('success'); 137 | } 138 | })(); 139 | return true; 140 | }); 141 | 142 | chrome.runtime.onMessageExternal.addListener((request, sender, sendResponse) => { 143 | if (request.meta == 'startVerifyCallback') { 144 | startVerifyCallbacks.push(sendResponse); 145 | console.log(startVerifyCallbacks); 146 | console.log(sendResponse); 147 | return true; 148 | } else if (request.meta == 'endVerifyCallback') { 149 | endVerifyCallbacks.push(sendResponse); 150 | return true; 151 | } else if (request.meta == 'verifying') { 152 | chrome.storage.local.get('verifyServerConnErr').then(res=>{ 153 | sendResponse(res.verifyServerConnErr ? 'nocon' : verifying); 154 | }); 155 | return true; 156 | } else if (request.meta=='getVerifyError') { 157 | getVerifyError().then(er=>sendResponse(er)); 158 | return true; 159 | } else if (request.meta == 'dontShowVerifyError') { 160 | chrome.storage.local.set({dontShowVerifyError:request.val}); 161 | } else if (request.meta == 'getShowVerifyError') { 162 | chrome.storage.local.get('dontShowVerifyError').then(res=>sendResponse(res.dontShowVerifyError)); 163 | return true; 164 | } 165 | }); 166 | 167 | // if it could all happen in background.js, it would look like this 168 | // async function authenticateScratch() { 169 | // let username = "" 170 | 171 | // let clientCode = Math.random().toString() 172 | 173 | // let verifyResponse = await (await fetch(`${apiUrl}/verify/start?code=${clientCode}`)).json() 174 | 175 | // let code = verifyResponse.code 176 | // let project = verifyResponse.project 177 | 178 | // await setCloudTempCode(code,project); 179 | 180 | // let tokenResponse = await (await fetch(`${apiUrl}/verify/userToken?code=${clientCode}`)).json() 181 | 182 | // storeLivescratchToken(tokenResponse.username,tokenResponse.token) 183 | 184 | // } 185 | function storeLivescratchToken(username,token,current) { 186 | username=username?.toLowerCase(); 187 | let toSet = {}; 188 | toSet[`blToken.${username}`] = token; 189 | chrome.storage.local.set(toSet); 190 | if(current) {currentBlToken = token;} 191 | } 192 | 193 | // async function getConfimationCode(tempCode) { 194 | // return await (await fetch(`${apiUrl}/verify/start?code=${tempCode}`)).json(); 195 | // } -------------------------------------------------------------------------------- /extension/background/livescratchProject.js: -------------------------------------------------------------------------------- 1 | class LivescratchProject { 2 | // projectJSON 3 | // projectJSONVersion = 0 4 | version = -1; 5 | changes = []; 6 | 7 | constructor() { 8 | } 9 | 10 | recordChange(change) { 11 | this.changes.push(change); 12 | this.version++; 13 | } 14 | 15 | getChangesSinceVersion(lastVersion) { 16 | return this.changes.slice(lastVersion); 17 | } 18 | 19 | hasHistory(untilVersion) { 20 | return (this.version-untilVersion) <= this.changes.length; 21 | } 22 | 23 | setVersion(toVersion) { 24 | if(toVersion > this.version) { 25 | this.changes = []; 26 | } 27 | this.version = toVersion; 28 | } 29 | } -------------------------------------------------------------------------------- /extension/css/editor.css: -------------------------------------------------------------------------------- 1 | .sharedName:hover { 2 | text-decoration: underline; 3 | } 4 | #resultName:hover { 5 | text-decoration: underline; 6 | } 7 | 8 | .result:hover { 9 | background: #6aa8ff; 10 | } -------------------------------------------------------------------------------- /extension/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlockliveScratch/Blocklive/b16adf130e0bf4acc84e22a8aaf677dd4a8f580b/extension/icon128.png -------------------------------------------------------------------------------- /extension/img/LogoLiveScratchFlat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /extension/img/icons/dark-switch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /extension/img/icons/light-switch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /extension/injectors/all.js: -------------------------------------------------------------------------------- 1 | console.log('injecting badge.js'); 2 | 3 | // alert(chrome.runtime.id) 4 | let scriptElemBadges = document.createElement('script'); 5 | scriptElemBadges.dataset.exId = chrome.runtime.id; 6 | scriptElemBadges.dataset.logoUrl = chrome.runtime.getURL('/img/LogoLiveScratch.svg'); 7 | scriptElemBadges.classList.add('livescratch-ext-2'); 8 | let srcThignBadges = chrome.runtime.getURL('/scripts/badge.js'); 9 | 10 | scriptElemBadges.src = srcThignBadges; 11 | // document.body.append(scriptElem) 12 | 13 | if (!!document.head) { 14 | document.head.appendChild(scriptElemBadges); 15 | } else { 16 | document.documentElement.appendChild(scriptElemBadges); 17 | } 18 | 19 | let scriptElemModal = document.createElement('script'); 20 | scriptElemModal.dataset.exId = chrome.runtime.id; 21 | scriptElemModal.dataset.logoUrl = chrome.runtime.getURL('/img/LogoLiveScratch.svg'); 22 | scriptElemModal.classList.add('livescratch-ext-2'); 23 | let srcThignModal = chrome.runtime.getURL('/UI_modal/index.js'); 24 | 25 | scriptElemModal.src = srcThignModal; 26 | // document.body.append(scriptElem) 27 | 28 | if (!!document.head) { 29 | document.head.appendChild(scriptElemModal); 30 | } else { 31 | document.documentElement.appendChild(scriptElemModal); 32 | } -------------------------------------------------------------------------------- /extension/injectors/editor.js: -------------------------------------------------------------------------------- 1 | console.log('injecting editor.js'); 2 | 3 | // alert(chrome.runtime.id) 4 | let scriptElem = document.createElement('script'); 5 | scriptElem.dataset.exId = chrome.runtime.id; 6 | scriptElem.classList.add('livescratch-ext'); 7 | let srcThign = chrome.runtime.getURL('/scripts/editor.js'); 8 | 9 | scriptElem.src = srcThign; 10 | // document.body.append(scriptElem) 11 | 12 | if (!!document.head) { 13 | document.head.appendChild(scriptElem); 14 | } else { 15 | document.documentElement.appendChild(scriptElem); 16 | } 17 | -------------------------------------------------------------------------------- /extension/injectors/mystuff.js: -------------------------------------------------------------------------------- 1 | console.log('injecting mystuff.js'); 2 | 3 | // alert(chrome.runtime.id) 4 | let scriptElem = document.createElement('script'); 5 | scriptElem.dataset.exId = chrome.runtime.id; 6 | scriptElem.classList.add('livescratch-ext'); 7 | let srcThign = chrome.runtime.getURL('/scripts/mystuff.js'); 8 | scriptElem.src = srcThign; 9 | // document.body.append(scriptElem) 10 | 11 | if(!!document.head) { 12 | document.head.appendChild(scriptElem); 13 | } else { 14 | document.documentElement.appendChild(scriptElem); 15 | } -------------------------------------------------------------------------------- /extension/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": {"include": ["chrome"]} 3 | } -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Livescratch: Live Scratch Collabs!", 3 | "description": "Multiple Scratchers Can Work Together On The Same Project!", 4 | "version": "0.1.1", 5 | "version_name": "0.2.0-pre", 6 | "icons": { 7 | "128": "icon128.png" 8 | }, 9 | "manifest_version": 3, 10 | "content_scripts": [ 11 | { 12 | "matches":["https://scratch.mit.edu/projects*"], 13 | "css":[], 14 | "js":["injectors/editor.js"] 15 | },{ 16 | "matches":["https://scratch.mit.edu/mystuff*"], 17 | "css":[], 18 | "js":["injectors/mystuff.js"] 19 | },{ 20 | "matches":["https://scratch.mit.edu/*"], 21 | "css":["scripts/badge.css", "UI_modal/style.css"], 22 | "js":["scripts/verify.js","injectors/all.js"] 23 | } 24 | ], 25 | "background": { 26 | "service_worker":"background.js" 27 | }, 28 | "permissions": [ 29 | "storage" 30 | ], 31 | "host_permissions":[ 32 | "https://scratch.mit.edu/" 33 | ], 34 | "optional_permissions":["notifications"], 35 | "web_accessible_resources" : [{ 36 | "resources":["/scripts/editor.js","/scripts/vm.js","/scripts/badge.js","/UI_modal/index.js","/scripts/mystuff.js","/scripts/turbowarp_editor.js","/img/LogoLiveScratch.svg","/sounds/ping.mp3"], 37 | "matches":[""] 38 | }], 39 | "externally_connectable": { 40 | "matches": ["https://scratch.mit.edu/*"] 41 | }, 42 | "action": { 43 | "default_popup": "popups/popup.html" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /extension/popups/popup.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Cookie&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); 2 | 3 | body { 4 | display: flex; 5 | width: 390px; 6 | flex-direction: column; 7 | align-items: flex-start; 8 | background: var(--BG); 9 | margin: 0; 10 | } 11 | 12 | * { 13 | font-family: Montserrat; 14 | font-style: normal; 15 | line-height: normal; 16 | font-weight: 400; 17 | font-size: 14.039px; 18 | color: var(--Text); 19 | } 20 | 21 | .material-symbols-outlined { 22 | font-variation-settings: 23 | 'FILL' 0, 24 | 'wght' 400, 25 | 'GRAD' -25, 26 | 'opsz' 20 27 | } 28 | 29 | header { 30 | display: flex; 31 | padding: 11px 15px; 32 | align-items: center; 33 | gap: 9px; 34 | align-self: stretch; 35 | z-index: 10; 36 | background: linear-gradient(100deg, #4C97FF -2.26%, #59C059 135.83%); 37 | box-shadow: 0px 0px 102.3px 0px rgba(0, 0, 0, 0.08); 38 | } 39 | 40 | header #logo { 41 | width: 52px; 42 | } 43 | 44 | header #title { 45 | color: #FFF; 46 | font-family: Montserrat; 47 | font-size: 23.398px; 48 | font-style: normal; 49 | font-weight: 500; 50 | line-height: normal; 51 | } 52 | 53 | header #version { 54 | color: rgba(255, 255, 255, 0.83); 55 | font-family: Montserrat; 56 | font-size: 15.599px; 57 | font-style: normal; 58 | font-weight: 400; 59 | line-height: normal; 60 | } 61 | 62 | header #switch-theme { 63 | width: 31.2px; 64 | height: 31.2px; 65 | position: absolute; 66 | right: 0; 67 | padding: 16.5px; 68 | } 69 | 70 | #contents { 71 | display: flex; 72 | padding: 15.599px 23.398px; 73 | flex-direction: column; 74 | align-items: flex-start; 75 | gap: 13.259px; 76 | align-self: stretch; 77 | justify-content: center; 78 | } 79 | 80 | #floating-logo-back { 81 | width: 165.068px; 82 | height: 172.163px; 83 | transform: rotate(-10.941deg); 84 | position: absolute; 85 | right: 19px; 86 | top: 77px; 87 | opacity: 0.50; 88 | filter: blur(40px); 89 | z-index: 0; 90 | } 91 | 92 | #floating-logo { 93 | width: 165.068px; 94 | height: 172.163px; 95 | transform: rotate(-10.941deg); 96 | position: absolute; 97 | right: 19px; 98 | top: 77px; 99 | opacity: 0.50; 100 | filter: blur(12px); 101 | z-index: 2; 102 | } 103 | 104 | #friends-list { 105 | margin-bottom: -45px; 106 | position: relative; 107 | display: flex; 108 | flex-direction: column; 109 | align-items: flex-start; 110 | gap: 7.799px; 111 | align-self: stretch; 112 | z-index: 10; 113 | } 114 | 115 | info { 116 | opacity: 0.5; 117 | z-index: 4; 118 | } 119 | 120 | .title { 121 | font-size: 23.398px; 122 | opacity: 1; 123 | z-index: 5; 124 | } 125 | 126 | .group { 127 | display: flex; 128 | flex-direction: column; 129 | } 130 | 131 | .input input { 132 | padding: 9.100000000000001px 14px; 133 | border-radius: 7.799px 0 0 7.799px; 134 | width: 100%; 135 | display: flex; 136 | align-items: stretch; 137 | border: 0.78px solid var(--Sub); 138 | border-right: none; 139 | background: var(--Sub); 140 | } 141 | 142 | .input input::placeholder{ 143 | color: var(--Text); 144 | opacity: 0.6; 145 | } 146 | 147 | .input input::-moz-placeholder{ 148 | color: var(--Text); 149 | opacity: 0.6; 150 | } 151 | 152 | .input input::-ms-input-placeholder{ 153 | color: var(--Text); 154 | opacity: 0.6; 155 | } 156 | 157 | .input input:-ms-input-placeholder{ 158 | color: var(--Text); 159 | opacity: 0.6; 160 | } 161 | 162 | .input input::-webkit-input-placeholder{ 163 | color: var(--Text); 164 | opacity: 0.6; 165 | } 166 | 167 | .input span { 168 | padding: 5px; 169 | border-radius: 0 7.799px 7.799px 0; 170 | display: flex; 171 | align-items: stretch; 172 | border: 0.78px solid var(--Sub); 173 | border-left: none; 174 | background: var(--Sub); 175 | } 176 | 177 | .input { 178 | display: flex; 179 | align-items: stretch; 180 | width: 100%; 181 | } 182 | 183 | #friends { 184 | min-height: 50px; 185 | display: flex; 186 | max-height: 148.176px; 187 | flex-direction: column; 188 | align-items: flex-start; 189 | gap: 5.46px; 190 | align-self: stretch; 191 | overflow-y: auto; 192 | padding: 0; 193 | margin: 0; 194 | list-style: none; 195 | } 196 | 197 | #friends li { 198 | display: flex; 199 | flex-direction: row; 200 | width: 100%; 201 | } 202 | 203 | #friends li > span{ 204 | display: flex; 205 | border: 0.78px solid var(--Supplementary); 206 | background: var(--Supplementary); 207 | } 208 | 209 | #friends li > span:nth-child(1){ 210 | width: 100%; 211 | padding: 9.100000000000001px 14px; 212 | border-radius: 7.799px 0 0 7.799px; 213 | border-right: none; 214 | } 215 | 216 | #friends li > span:nth-child(2){ 217 | padding: 5px; 218 | border-radius: 0 7.799px 7.799px 0; 219 | border-left: none; 220 | } 221 | 222 | #loggedout { 223 | padding: 15.599px 23.398px; 224 | font-size: 23.398px; 225 | flex-direction: column; 226 | z-index: 10; 227 | } 228 | 229 | .button:hover { 230 | cursor: pointer; 231 | } 232 | 233 | #projects { 234 | transition: 0.2s; 235 | opacity: 1; 236 | position: absolute; 237 | color: #FFFFFF; 238 | display: flex; 239 | padding: 8.579px 19.26px 7.516px 19.5px; 240 | justify-content: center; 241 | align-items: center; 242 | position: relative; 243 | left: 96.72px; 244 | bottom: 50px; 245 | border-radius: 7.799px; 246 | border: 0.78px solid var(--button-large-stroke); 247 | background: var(--button-large); 248 | box-shadow: 0px 3.12px 13.337px 0px rgba(0, 0, 0, 0.25); 249 | } 250 | 251 | section { 252 | display: flex; 253 | flex-direction: column; 254 | gap: 3px; 255 | width: 100%; 256 | } 257 | 258 | #project-chat { 259 | z-index: 20; 260 | } 261 | 262 | /* Sliders */ 263 | .switch { 264 | position: relative; 265 | display: inline-block; 266 | width: 41px; 267 | height: 21px; 268 | } 269 | 270 | /* Hide default HTML checkbox */ 271 | .switch input { 272 | opacity: 0; 273 | width: 0; 274 | height: 0; 275 | } 276 | 277 | /* The slider */ 278 | .slider { 279 | position: absolute; 280 | cursor: pointer; 281 | top: 0; 282 | left: 0; 283 | right: 0; 284 | bottom: 0; 285 | background-color: var(--Sub); 286 | border: 1px solid var(--Sub); 287 | -webkit-transition: .4s; 288 | transition: .4s; 289 | } 290 | 291 | .slider:before { 292 | position: absolute; 293 | content: ""; 294 | height: 15.5px; 295 | width: 15.5px; 296 | left: 2px; 297 | bottom: 2px; 298 | background-color: var(--Text); 299 | -webkit-transition: .4s; 300 | transition: .4s; 301 | } 302 | 303 | .switch input:checked + .slider { 304 | background: var(--switch-enabled); 305 | } 306 | 307 | .switch input:checked + .slider::before { 308 | background-color: #FFFFFF; 309 | } 310 | 311 | .switch input:checked + .slider:before { 312 | -webkit-transform: translateX(19.5px); 313 | -ms-transform: translateX(19.5px); 314 | transform: translateX(19.5px); 315 | } 316 | 317 | /* Rounded sliders */ 318 | .slider.round { 319 | border-radius: 34px; 320 | } 321 | 322 | .slider.round:before { 323 | border-radius: 50%; 324 | } 325 | 326 | /* end of sliders code */ 327 | 328 | .popup { 329 | display: flex; 330 | width: 311.22px; 331 | padding: 7.8px 15.6px; 332 | flex-direction: column; 333 | justify-content: center; 334 | align-items: center; 335 | gap: 6.24px; 336 | position: absolute; 337 | border-radius: 7.8px; 338 | border: 0.78px solid var(--Light-Mode---Sub, rgba(255, 255, 255, 0.57)); 339 | background: rgba(255, 238, 86, 0.25); 340 | box-shadow: 0px 4px 34.9px 0px rgba(0, 0, 0, 0.25); 341 | backdrop-filter: blur(49.20000076293945px); 342 | z-index: 30; 343 | } 344 | 345 | footer { 346 | display: flex; 347 | width: 342px; 348 | padding: 15.599px 23.398px; 349 | flex-direction: column; 350 | align-items: flex-start; 351 | gap: 7.799px; 352 | background: var(--Supplementary); 353 | box-shadow: 0px 0px 79.788px 0px rgba(0, 0, 0, 0.08); 354 | font-size: 15.599px; 355 | z-index: 10; 356 | } 357 | 358 | .flex-horiz { 359 | display: flex; 360 | align-items: flex-start; 361 | gap: 10.139px; 362 | } 363 | 364 | .credit { 365 | display: flex; 366 | padding: 1.56px 10.919px 1.56px 1.56px; 367 | align-items: center; 368 | gap: 6.239px; 369 | width: 100%; 370 | border-radius: 77.994px; 371 | border: 0.78px solid var(--Sub); 372 | background: var(--Sub); 373 | } 374 | 375 | .credit img { 376 | width: 30.42px; 377 | height: 30.415px; 378 | clip-path: circle(); 379 | } 380 | 381 | #links a img{ 382 | height: 20px; 383 | } 384 | 385 | #links a#link-hub { 386 | background-color: var(--Sub-Semi); 387 | border-color: var(--Sub-Semi); 388 | z-index: 100; 389 | cursor: pointer; 390 | } 391 | 392 | #links a#link-hub img, #links a#link-hub span:not(.tooltiptext) { 393 | opacity: 0.7; 394 | } 395 | 396 | #links a { 397 | background-color: var(--Sub); 398 | padding: 3px 5px; 399 | border-radius: 50px; 400 | border: 1px solid var(--Sub); 401 | z-index: 2; 402 | } 403 | 404 | hr { 405 | width: 100px; 406 | display: block; 407 | height: 1px; 408 | border: 0; 409 | border-top: 1px solid var(--Text); 410 | padding: 0; 411 | opacity: 0.5; 412 | margin-block: 1px; 413 | } 414 | 415 | .tooltip { 416 | position: relative; 417 | } 418 | 419 | .tooltip .tooltiptext { 420 | visibility: hidden; 421 | width: 120px; 422 | background-color: #555; 423 | color: #fff; 424 | text-align: center; 425 | border-radius: 6px; 426 | padding: 5px 0; 427 | position: absolute; 428 | z-index: 100; 429 | opacity: 0; 430 | transition: opacity 0.3s; 431 | } 432 | 433 | .tooltip-right::after { 434 | content: ""; 435 | position: absolute; 436 | top: 50%; 437 | right: 100%; 438 | margin-top: -5px; 439 | border-width: 5px; 440 | border-style: solid; 441 | border-color: transparent #555 transparent transparent; 442 | } 443 | 444 | .tooltip:hover .tooltiptext { 445 | visibility: visible; 446 | opacity: 1; 447 | } 448 | .tooltip-right { 449 | top: -5px; 450 | left: 125%; 451 | } 452 | #links a#link-donate { 453 | background-color: #202020; 454 | text-decoration: none; 455 | padding-inline: 7px; 456 | } 457 | 458 | #links a#link-donate img { 459 | height: 15px; 460 | } 461 | 462 | #links a#link-donate span { 463 | color: #ffffff; 464 | font-family: "Cookie", cursive; 465 | font-size: 18px; 466 | } 467 | 468 | #server-url { 469 | background-color: var(--Sub); 470 | color: var(--Text); 471 | border: 0.78px solid var(--Sub); 472 | border-radius: 5px; 473 | } 474 | 475 | #server-url:disabled { 476 | opacity: 0.7; 477 | } 478 | 479 | #settings { 480 | padding-left: 5px; 481 | display: flex; 482 | } 483 | 484 | #settings-dropdown { 485 | cursor: pointer; 486 | } 487 | 488 | body.dark-mode *, body.dark-mode{ 489 | --BG: linear-gradient(147deg, #204635 -9.97%, #25446D 134.99%); 490 | --Text: #FFF; 491 | --Text-Solid: #FFF; 492 | --Supplementary: rgba(255, 255, 255, 0.14); 493 | --Sub: rgba(255, 255, 255, 0.337); 494 | --Sub-Semi: rgba(255, 255, 255, 0.1); 495 | --button-large-stroke: rgba(255, 255, 255, 0.31); 496 | --button-large: linear-gradient(115deg, #4794F6 -12.84%, #55B488 122.07%); 497 | --switch-enabled: linear-gradient(116deg, #56B48B 0%, #55AAB5 78.39%); 498 | } 499 | 500 | body.dark-mode #credits .credit, #links a { 501 | background-color: var(--Supplementary); 502 | border-color: var(--Supplementary); 503 | } 504 | 505 | body.light-mode *, body.light-mode{ 506 | --BG: linear-gradient(147deg, #E2FFE2 -9.97%, #D8E8FF 134.99%); 507 | --Text: #2c2c2c; 508 | --Text-Solid: #000000; 509 | --Supplementary: rgba(255, 255, 255, 0.29); 510 | --Sub: rgba(255, 255, 255, 0.611); 511 | --Sub-Semi: rgba(255, 255, 255, 0.4); 512 | --button-large-stroke: rgba(255, 255, 255, 0.58); 513 | --button-large: linear-gradient(115deg, #4A9AFF -12.84%, #57B78A 122.07%); 514 | --switch-enabled: linear-gradient(116deg, #56B48B 0%, #55AAB5 78.39%); 515 | } 516 | 517 | body.light-mode #link-uptime img { 518 | filter: brightness(0.7); 519 | } 520 | 521 | body.light-mode #switch-theme { 522 | background-image: url("../img/icons/light-switch.svg"); 523 | background-repeat: no-repeat; 524 | background-position: center; 525 | } 526 | 527 | body.dark-mode #switch-theme { 528 | background-image: url("../img/icons/dark-switch.svg"); 529 | background-repeat: no-repeat; 530 | background-position: center; 531 | } -------------------------------------------------------------------------------- /extension/popups/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | LiveScratch 15 | 16 | 17 |
18 | 19 | 25 | 26 |
27 | 28 | 29 |
30 |
31 | Your Friends List: 32 | Allow collaborating with friends 33 |
34 | 35 |
36 | 37 | add 38 |
39 | 40 |
    41 |
42 | 43 | 44 |
45 | 46 |
47 | 48 | settingsSettingsarrow_drop_down 49 |
50 |
51 | Project Chats: 52 |
53 |
54 | 55 | 56 |
57 | 58 |
59 | 60 | 61 |
62 |
63 |
64 | 65 |
66 |
67 | Custom Server: 68 | Warning: Advanced Setting 69 |
70 |
71 |
72 | 73 | 79 |
80 |
81 |
82 | 83 |
84 |
85 | 89 | Show Badges 90 |
91 | 92 |
93 | 94 | 95 |
96 |
97 |
98 |
99 | 100 | 105 | 106 | 110 |
111 | 112 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /extension/popups/script.js: -------------------------------------------------------------------------------- 1 | chrome.storage.local.get('theme', (data) => { 2 | const savedTheme = (data.theme || 'light') + '-mode'; 3 | document.querySelector('body').classList.replace('light-mode', savedTheme); 4 | }); 5 | 6 | var version = chrome.runtime.getManifest().version_name; 7 | document.querySelector('#version').innerHTML = 'v'+version; 8 | 9 | document.getElementById('server-url').defaultValue = 'https://livescratchapi.waakul.com'; 10 | 11 | { 12 | (async () => { 13 | const data = await chrome.storage.local.get('custom-server'); 14 | const value = data['custom-server']; 15 | 16 | document.getElementById('server-url').disabled = !value; 17 | document.getElementById('custom-server').checked = !!value; 18 | })(); 19 | } 20 | { 21 | (async () => { 22 | const data = await chrome.storage.local.get('server-url'); 23 | const serverUrlValue = data['server-url']; 24 | 25 | document.getElementById('server-url').value = serverUrlValue || 'https://livescratchapi.waakul.com'; 26 | })(); 27 | } 28 | 29 | document.getElementById('switch-theme')?.addEventListener('click', function(){ 30 | chrome.storage.local.get('theme', (data) => { 31 | let theme = data['theme'] || 'light'; 32 | let newTheme = theme == 'light' ? 'dark' : 'light'; 33 | chrome.storage.local.set({'theme': newTheme}); 34 | document.querySelector('body').classList.replace(`${theme}-mode`, `${newTheme}-mode`); 35 | }); 36 | }); 37 | 38 | let settingsDropdown = false; 39 | document.getElementById('settings').style.display = settingsDropdown ? 'flex' : 'none'; 40 | document.getElementById('settings-dropdown')?.addEventListener('click', function() { 41 | settingsDropdown = !settingsDropdown; 42 | console.log(settingsDropdown); 43 | document.getElementById('settings').style.display = settingsDropdown ? 'flex' : 'none'; 44 | }); 45 | 46 | document.querySelector('button#projects')?.addEventListener('click', function () { 47 | chrome.tabs.create({ 48 | url: '/projects/index.html', 49 | }); 50 | }); 51 | 52 | function validateUrl(input) { 53 | const value = input.value; 54 | const regex = new RegExp('^(https?:\/\/)([a-zA-Z0-9.-]+)(:[0-9]{1,5})?$'); 55 | return regex.test(value); 56 | } 57 | 58 | document.querySelectorAll('button.credit').forEach(function(credit){ 59 | credit.onclick = () => { 60 | let username = credit.querySelector('.credit-name').innerText; 61 | chrome.tabs.create({ 62 | url: `https://scratch.mit.edu/users/${username}`, 63 | }); 64 | }; 65 | }); 66 | 67 | chrome.runtime.sendMessage({ meta: 'getUsernamePlus' }, function (info) { 68 | let username = info.uname; 69 | let token = info.currentBlToken; 70 | let apiUrl = info.apiUrl; 71 | 72 | document.querySelector('input#custom-server')?.addEventListener('change', function () { 73 | const input = document.querySelector('input#custom-server'); 74 | const serverUrlField = document.getElementById('server-url'); 75 | 76 | const value = input.checked; 77 | console.log(value); 78 | chrome.storage.local.set({ 'custom-server': value }); 79 | serverUrlField.disabled = !value; 80 | 81 | if (serverUrlField.value!=='https://livescratchapi.waakul.com') { 82 | chrome.runtime.sendMessage({meta: 'clearCrntToken'}, function(){ 83 | chrome.storage.local.remove( 84 | [`blToken.${username}`, 'dontShowVerifyError', 'uname', 'upk', 'verifyServerConnErr'], 85 | function () { 86 | if (chrome.runtime.lastError) { 87 | console.error('Error removing keys:', chrome.runtime.lastError); 88 | } else { 89 | console.log('Keys successfully removed.'); 90 | chrome.storage.local.set({ 'apiUpdateReload': true }, function() { 91 | chrome.runtime.reload(); 92 | }); 93 | } 94 | }, 95 | ); 96 | }); 97 | } 98 | }); 99 | 100 | document.querySelector('input#server-url')?.addEventListener('change', function () { 101 | value = document.querySelector('input#server-url').value; 102 | if (!value) { 103 | document.querySelector('input#server-url').value = 'https://livescratchapi.waakul.com'; 104 | chrome.storage.local.set({'server-url': 'https://livescratchapi.waakul.com'}); 105 | } else { 106 | valid = validateUrl(document.querySelector('input#server-url')); 107 | console.log(valid); 108 | if (valid) { 109 | chrome.storage.local.set({'server-url': value}); 110 | } else { 111 | document.querySelector('input#server-url').value = 'https://livescratchapi.waakul.com'; 112 | chrome.storage.local.set({'server-url': 'https://livescratchapi.waakul.com'}); 113 | } 114 | } 115 | chrome.sendMessage({meta: 'clearCrntToken'}, function(){ 116 | chrome.storage.local.remove( 117 | [`blToken.${username}`, 'dontShowVerifyError', 'uname', 'upk', 'verifyServerConnErr'], 118 | function () { 119 | if (chrome.runtime.lastError) { 120 | console.error('Error removing keys:', chrome.runtime.lastError); 121 | } else { 122 | console.log('Keys successfully removed.'); 123 | chrome.storage.local.set({ 'apiUpdateReload': true }, function() { 124 | chrome.runtime.reload(); 125 | }); 126 | } 127 | }, 128 | ); 129 | }); 130 | }); 131 | 132 | function setSignedin(info) { 133 | 134 | if (info.signedin) { 135 | document.querySelector('#loggedout').style.display = 'none'; 136 | document.querySelector('#contents').style.display = 'flex'; 137 | token = info.currentBlToken; 138 | username = info.uname; 139 | } else { 140 | document.querySelector('#loggedout').style.display = 'flex'; 141 | document.querySelector('#contents').style.display = 'none'; 142 | } 143 | } 144 | setSignedin(info); 145 | 146 | setTimeout(() => { chrome.runtime.sendMessage({ meta: 'getUsernamePlus' }, setSignedin); }, 1000); 147 | 148 | let alreadyAdded = {}; 149 | 150 | // credit https://stackoverflow.com/questions/2794137/sanitizing-user-input-before-adding-it-to-the-dom-in-javascript 151 | function sanitize(string) { 152 | string = String(string); 153 | const map = { 154 | '&': '&', 155 | '<': '<', 156 | '>': '>', 157 | '"': '"', 158 | '\'': ''', 159 | '/': '/', 160 | }; 161 | const reg = /[&<>"'/]/ig; 162 | return string.replace(reg, (match) => (map[match])); 163 | } 164 | 165 | function addFriendGUI(name) { 166 | if (name?.toLowerCase() in alreadyAdded) { return; } 167 | alreadyAdded[name.toLowerCase()] = true; 168 | 169 | let item = document.createElement('li'); 170 | item.username = name; 171 | item.innerHTML = `@${sanitize(name)}remove`; 172 | item.onclick = (e) => { 173 | if (e.target?.classList?.contains('x')) { 174 | removeFriend(name); 175 | document.querySelector('#projects').style.opacity = 1; 176 | } 177 | else { chrome.tabs.create({ url: `https://scratch.mit.edu/users/${name}` }); } 178 | }; 179 | item.onmouseenter = (e) => { 180 | document.querySelector('#projects').style.opacity = 0.5; 181 | }; 182 | item.onmouseleave = (e) => { 183 | document.querySelector('#projects').style.opacity = 1; 184 | }; 185 | document.querySelector('#friends').appendChild(item); 186 | } 187 | 188 | async function addFriend(name) { 189 | if (name.toLowerCase() in alreadyAdded) { return; } 190 | if (name.toLowerCase() == username.toLowerCase()) { return; } 191 | if (!name.trim()) { return; } 192 | if (name.includes(' ')) { return; } 193 | document.querySelector('#add').value = ''; 194 | 195 | let response = await fetch(`${apiUrl}/friends/${username}/${name}`, { method: 'POST', headers: { authorization: token } }); 196 | let statusCode = response.status; 197 | if (statusCode===200) { 198 | addFriendGUI(name); 199 | } else { 200 | alert('The user you tried to friend doesnt have livescratch!'); 201 | } 202 | } 203 | 204 | function removeFriend(name) { 205 | delete alreadyAdded[name.toLowerCase()]; 206 | for (let child of document.querySelector('#friends').children) { 207 | if (child.username == name) { child.remove(); break; } 208 | } 209 | fetch(`${apiUrl}/friends/${username}/${name}`, { method: 'DELETE', headers: { authorization: token } }); 210 | } 211 | 212 | document.querySelector('#add')?.addEventListener('keyup', function (event) { 213 | if (event.keyCode === 13) { 214 | addFriend(document.querySelector('#add').value); 215 | } 216 | }); 217 | 218 | document.querySelector('#submit').onclick = () => { addFriend(document.querySelector('#add').value); }; 219 | 220 | if (!info.currentBlToken && !info.verifyBypass) { 221 | showNoAuthMessage(); 222 | } else { 223 | fetch(`${apiUrl}/friends/${username}`, { headers: { authorization: token } }) 224 | .then((res) => { document.querySelector('#friends').innerHTML = ''; return res; }) 225 | .then(res => res.json().then(list => { 226 | if (list.noauth) { showNoAuthMessage(); } 227 | else { list.forEach(addFriendGUI); } 228 | })) 229 | .catch((e) => { 230 | document.querySelector('#error').style.display = 'inherit'; 231 | document.querySelector('#error-content').innerHTML = e.stack.replace(new RegExp(`chrome-extension://${chrome.runtime.id}/`, 'g'), ''); 232 | }); 233 | } 234 | 235 | { 236 | (async () => { 237 | document.querySelector('#privme').checked = await (await fetch(`${apiUrl}/privateMe/${username}`, { headers: { authorization: token } })).json(); 238 | })(); 239 | } 240 | 241 | document.querySelector('#privme')?.addEventListener('change', (event) => { 242 | let on = event.currentTarget.checked; 243 | 244 | fetch(`${apiUrl}/privateMe/${username}/${on}`, {method:'put', headers: { authorization: token } }); 245 | }); 246 | }); 247 | 248 | function showNoAuthMessage() { 249 | document.querySelector('#not-verified').style.display = 'inherit'; 250 | } 251 | 252 | document.getElementById('link-uptime').onclick = () => { 253 | chrome.tabs.create({ url: 'https://status.uptime-monitor.io/67497373f98a6334aaea672d' }); 254 | }; 255 | document.getElementById('link-donate').onclick = () => { 256 | chrome.tabs.create({ url: 'https://ko-fi.com/waakul' }); 257 | }; 258 | 259 | (async () => { 260 | document.querySelector('#notifs').checked = (await chrome.storage.local.get(['notifs']))?.notifs ?? false; 261 | })(); 262 | 263 | document.querySelector('#notifs')?.addEventListener('change', (event) => { 264 | let on = event.currentTarget.checked; 265 | chrome.storage.local.set({ notifs: on }); 266 | // Permissions must be requested from inside a user gesture, like a button's 267 | // click handler. 268 | chrome.permissions.request({ 269 | permissions: ['notifications'], 270 | }, (granted) => { 271 | // The callback argument will be true if the user granted the permissions. 272 | console.log(granted); 273 | if(!granted) { 274 | chrome.storage.local.set({ notifs: false }); 275 | document.querySelector('#notifs').checked = false; 276 | } 277 | }); 278 | }); 279 | 280 | (async () => { 281 | document.querySelector('#ping-sounds').checked = (await chrome.storage.local.get(['ping']))?.ping ?? false; 282 | document.querySelector('#badges').checked = !((await chrome.storage.local.get(['badges']))?.badges ?? false); 283 | })(); 284 | 285 | document.querySelector('#ping-sounds')?.addEventListener('change', (event) => { 286 | let on = event.currentTarget.checked; 287 | chrome.storage.local.set({ ping: on }); 288 | // Permissions must be requested from inside a user gesture, like a button's 289 | // click handler. 290 | }); 291 | 292 | document.querySelector('#badges')?.addEventListener('change', (event) => { 293 | let on = event.currentTarget.checked; 294 | chrome.storage.local.set({ badges: !on }); 295 | }); -------------------------------------------------------------------------------- /extension/projects/dark-colors.css: -------------------------------------------------------------------------------- 1 | *{ 2 | --BG: linear-gradient(147deg, #204635 -9.97%, #25446D 134.99%); 3 | --Text: #FFF; 4 | --Supplementary: rgba(255, 255, 255, 0.14); 5 | --Sub: rgba(255, 255, 255, 0.337); 6 | --Sub-Semi: rgba(255, 255, 255, 0.1); 7 | --button-large-stroke: rgba(255, 255, 255, 0.31); 8 | --button-large: linear-gradient(115deg, #4794F6 -12.84%, #55B488 122.07%); 9 | --switch-enabled: linear-gradient(116deg, #56B48B 0%, #55AAB5 78.39%); 10 | } 11 | 12 | #credits .credit, #links a { 13 | background-color: var(--Supplementary); 14 | border-color: var(--Supplementary); 15 | } -------------------------------------------------------------------------------- /extension/projects/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Projects | Livescratch 9 | 10 | 11 | 12 |
13 | 14 | LiveScratch 15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 | My Projects 23 | These are all of the LiveScratch projects that you have access to. Clicking on them will open them in the editor. 24 |
25 |
26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /extension/projects/light-colors.css: -------------------------------------------------------------------------------- 1 | *{ 2 | --BG: linear-gradient(147deg, #E2FFE2 -9.97%, #D8E8FF 134.99%); 3 | --Text: #2c2c2c; 4 | --Supplementary: rgba(255, 255, 255, 0.29); 5 | --Sub: rgba(255, 255, 255, 0.611); 6 | --Sub-Semi: rgba(255, 255, 255, 0.4); 7 | --button-large-stroke: rgba(255, 255, 255, 0.58); 8 | --button-large: linear-gradient(115deg, #4A9AFF -12.84%, #57B78A 122.07%); 9 | --switch-enabled: linear-gradient(116deg, #56B48B 0%, #55AAB5 78.39%); 10 | } 11 | 12 | #link-uptime img { 13 | filter: brightness(0.7); 14 | } -------------------------------------------------------------------------------- /extension/projects/script.js: -------------------------------------------------------------------------------- 1 | var version = chrome.runtime.getManifest().version; 2 | document.querySelector('#version').innerHTML = 'v'+version; 3 | 4 | async function getProjects() { 5 | let info = await chrome.runtime.sendMessage({meta:'getUsernamePlus'}); 6 | let apiUrl = info.apiUrl; 7 | let blToken = info.token; 8 | let uname = info.uname; 9 | 10 | var data = await ( 11 | await fetch( 12 | `${apiUrl}/userProjectsScratch/${uname}/`, 13 | {headers:{authorization:blToken}}, 14 | ) 15 | ).json(); 16 | if (data.length === 0) { 17 | var span = document.createElement('span'); 18 | span.className = 'title'; 19 | span.textContent = 'Nothing here! LiveScratch share a project to see it here!'; 20 | document.querySelector('.projects').appendChild(span); 21 | } 22 | data.forEach(function (project) { 23 | var div = document.createElement('div'); 24 | div.className = 'project'; 25 | 26 | var img = document.createElement('img'); 27 | img.src = `https://cdn2.scratch.mit.edu/get_image/project/${project.scratchId}_480x360.png`; 28 | 29 | var title = document.createElement('span'); 30 | title.className = 'title'; 31 | title.textContent = project.title; 32 | 33 | div.appendChild(img); 34 | div.appendChild(title); 35 | document.querySelector('.projects').appendChild(div); 36 | 37 | div.addEventListener('click', function () { 38 | chrome.tabs.create({ 39 | url: `https://scratch.mit.edu/projects/${project.scratchId}/editor`, 40 | }); 41 | }); 42 | }); 43 | } 44 | getProjects(); 45 | -------------------------------------------------------------------------------- /extension/projects/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | background: var(--BG); 6 | height: 100vh; 7 | margin: 0; 8 | } 9 | * { 10 | font-family: Montserrat; 11 | font-style: normal; 12 | line-height: normal; 13 | font-weight: 400; 14 | font-size: 14.039px; 15 | color: var(--Text); 16 | } 17 | 18 | header { 19 | display: flex; 20 | padding: 11px 15px; 21 | align-items: center; 22 | gap: 9px; 23 | align-self: stretch; 24 | z-index: 10; 25 | background: linear-gradient(100deg, #4C97FF -2.26%, #59C059 135.83%); 26 | box-shadow: 0px 0px 102.3px 0px rgba(0, 0, 0, 0.08); 27 | } 28 | 29 | header #logo { 30 | width: 52px; 31 | } 32 | 33 | header #title { 34 | color: #FFF; 35 | font-family: Montserrat; 36 | font-size: 23.398px; 37 | font-style: normal; 38 | font-weight: 500; 39 | line-height: normal; 40 | } 41 | 42 | header #version { 43 | color: rgba(255, 255, 255, 0.83); 44 | font-family: Montserrat; 45 | font-size: 15.599px; 46 | font-style: normal; 47 | font-weight: 400; 48 | line-height: normal; 49 | } 50 | 51 | header #switch-theme { 52 | width: 31.2px; 53 | height: 31.2px; 54 | position: absolute; 55 | right: 0; 56 | padding: 18px; 57 | cursor: pointer; 58 | } 59 | 60 | .group { 61 | display: flex; 62 | flex-direction: column; 63 | } 64 | 65 | .title { 66 | font-size: 23.398px; 67 | opacity: 1; 68 | z-index: 5; 69 | } 70 | 71 | info { 72 | opacity: 0.5; 73 | z-index: 4; 74 | } 75 | 76 | #contents { 77 | display: flex; 78 | padding: 15.599px 23.398px; 79 | flex-direction: column; 80 | align-items: flex-start; 81 | gap: 13.259px; 82 | align-self: stretch; 83 | justify-content: center; 84 | } 85 | 86 | #floating-logo-back { 87 | width: 165.068px; 88 | height: 172.163px; 89 | transform: rotate(-10.941deg); 90 | position: absolute; 91 | right: 40px; 92 | top: 100px; 93 | opacity: 0.50; 94 | filter: blur(40px); 95 | z-index: 0; 96 | } 97 | 98 | #floating-logo { 99 | width: 165.068px; 100 | height: 172.163px; 101 | transform: rotate(-10.941deg); 102 | position: absolute; 103 | right: 40px; 104 | top: 100px; 105 | opacity: 0.50; 106 | filter: blur(12px); 107 | z-index: 2; 108 | } 109 | 110 | .project { 111 | width: 15rem; 112 | display: inline-block; 113 | background-color: var(--Sub); 114 | padding: 0.8rem; 115 | border-radius: 0.5rem; 116 | margin: 0.5rem; 117 | cursor: pointer; 118 | transform: scale(1); 119 | transition: transform 0.3s; 120 | } 121 | .project:hover { 122 | transform: scale(1.05); 123 | } 124 | 125 | .project img { 126 | width: 100%; 127 | border-radius: 0.5rem; 128 | } 129 | 130 | .project .title { 131 | color: var(--Text); 132 | margin-top: 0.5px; 133 | display: block; 134 | font-weight: bold; 135 | font-size: 1rem; 136 | margin-left: 0.25rem; 137 | overflow-y: auto; 138 | } -------------------------------------------------------------------------------- /extension/projects/theme.js: -------------------------------------------------------------------------------- 1 | function loadTheme(theme) { 2 | const link = document.createElement('link'); 3 | link.rel = 'stylesheet'; 4 | link.href = theme + '-colors.css'; 5 | document.head.appendChild(link); 6 | 7 | document.querySelector('#switch-theme').src = `../img/icons/${theme}-switch.svg`; 8 | } 9 | 10 | function toggleTheme() { 11 | chrome.storage.local.get('theme', (data) => { 12 | const currentTheme = data.theme || 'light'; 13 | const newTheme = currentTheme === 'light' ? 'dark' : 'light'; 14 | 15 | // Remove existing theme stylesheet 16 | const existingLink = document.querySelector('link[rel=stylesheet][href*="-colors.css"]'); 17 | if (existingLink) { 18 | existingLink.remove(); 19 | } 20 | 21 | // Load new theme stylesheet 22 | loadTheme(newTheme); 23 | 24 | // Save the new theme in local storage 25 | chrome.storage.local.set({ 'theme': newTheme }); 26 | }); 27 | } 28 | 29 | document.querySelector('#switch-theme').addEventListener('click', toggleTheme); 30 | 31 | // Load the saved theme on page load 32 | window.onload = () => { 33 | chrome.storage.local.get('theme', (data) => { 34 | const savedTheme = data.theme || 'light'; 35 | loadTheme(savedTheme); 36 | }); 37 | }; -------------------------------------------------------------------------------- /extension/scripts/badge.css: -------------------------------------------------------------------------------- 1 | .blbadge { 2 | transition: 0.4s; 3 | transition-property: rotate, transform; 4 | } 5 | .blbadge:hover { 6 | rotate:360deg; 7 | } -------------------------------------------------------------------------------- /extension/scripts/badge.js: -------------------------------------------------------------------------------- 1 | { 2 | 3 | // document.querySelectorAll('a[href*=/users/]').forEach(a=>{ 4 | // let image = document.createElement('img'); 5 | // image.src = create 6 | // a.after() 7 | // }) 8 | 9 | 10 | let selectors = [ 11 | '.header-text>h2', 12 | 'a.username[href*="/users/"]', 13 | '.title>a[href*="/users/"]', 14 | '.thumbnail-creator>a[href*="/users/"]', 15 | '#favorites .owner>a[href*="/users/"]', 16 | '.name>a[href*="/users/"]', 17 | '.activity-ul a[href*="/users/"]', 18 | // '.content a[href*="/users/"]', // in comment 19 | '.studio-project-username', 20 | ]; 21 | 22 | const logoUrl2 = document.querySelector('.livescratch-ext-2').dataset.logoUrl; 23 | const exIdBadges = document.querySelector('.livescratch-ext-2').dataset.exId; 24 | 25 | async function displayBLUsers2(element) { 26 | let amIPriv = null; 27 | Array.from(element.querySelectorAll(selectors)).forEach( nameElem => { 28 | if (nameElem.seen) return; 29 | nameElem.seen = true; 30 | console.log(nameElem.innerText); 31 | 32 | chrome.runtime.sendMessage(exIdBadges, { meta: 'getUsernamePlus' }, async function (info) { 33 | let username = info.uname; 34 | let token = info.currentBlToken; 35 | let apiUrl = info.apiUrl; 36 | 37 | if (nameElem.innerText==username) { 38 | if (amIPriv==null) { 39 | amIPriv = await (await fetch(`${apiUrl}/privateMe/${username}`, { headers: { authorization: token } })).json(); 40 | } 41 | if (amIPriv) return; 42 | } 43 | 44 | chrome.runtime.sendMessage(exIdBadges, { meta: 'badges?', username: nameElem.innerText }, function (response) { 45 | if (!response.badges) { 46 | 47 | chrome.runtime.sendMessage(exIdBadges, { meta: 'userExists', username: nameElem.innerText }, function (response) { 48 | if (!response) return; 49 | 50 | let textSize = window.getComputedStyle(nameElem, null).getPropertyValue('font-size'); 51 | textSize = parseFloat(textSize.replace('px', '')); 52 | 53 | let img = document.createElement('img'); 54 | img.src = logoUrl2; 55 | let height = Math.max(16, textSize * 1.2); 56 | img.style.height = `${height}px`; 57 | img.style.marginTop = -height / 2 + 'px'; 58 | img.style.marginBottom = -height / 2 + 'px'; 59 | img.style.width = 'fit-content'; 60 | img.style.border = 'none'; 61 | img.style.outline = 'none'; 62 | img.style.padding = 'none'; 63 | img.style.margin = 'none'; 64 | img.style.display = 'flex'; 65 | img.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))'; 66 | img.classList.add('blbadge'); 67 | // img.style.filter = 'drop-shadow(0 4px 2px rgba(0,0,0,0.5))' 68 | img.setAttribute('title', 'This user uses livescratch'); 69 | 70 | 71 | let container = document.createElement('span'); 72 | nameElem.replaceWith(container); 73 | 74 | container.appendChild(nameElem); 75 | container.appendChild(img); 76 | container.style.display = 'inline flex'; 77 | container.style.flexFlow = 'row nowrap'; 78 | container.style.gap = '3px'; 79 | container.style.alignItems = 'center'; 80 | container.style.justifyContent = 'center'; 81 | container.style.alignSelf = 'flex-start'; 82 | container.style.maxWidth = '100%'; 83 | container.style.marginRight = 'auto'; 84 | container.style.overflow = 'visible'; 85 | container.parentElement.style.overflow = 'visible'; 86 | 87 | nameElem.style.textOverflow = 'unset'; 88 | 89 | // let fullThing = container.parentElement; 90 | // fullThing.style.display = 'flex'; 91 | // fullThing.style.gap = '3px'; 92 | // container.style.alignItems = 'center' 93 | 94 | // nameElem.after(img) 95 | 96 | // name.after(img) 97 | }); 98 | } 99 | }); 100 | }); 101 | }); 102 | } 103 | displayBLUsers2(document); 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | /// mutation obersver 112 | 113 | // Select the node that will be observed for mutations 114 | const targetNode2 = document.documentElement; 115 | 116 | // Options for the observer (which mutations to observe) 117 | const config2 = { attributes: true, childList: true, subtree: true }; 118 | 119 | // Callback function to execute when mutations are observed 120 | // const callback = (mutationList, observer) => { 121 | // for (const mutation of mutationList) { 122 | // if (mutation.type === "childList") { 123 | // console.log("A child node has been added or removed."); 124 | // } else if (mutation.type === "attributes") { 125 | // console.log(`The ${mutation.attributeName} attribute was modified.`); 126 | // } 127 | // } 128 | // }; 129 | 130 | // Create an observer instance linked to the callback function 131 | // const observer = new MutationObserver((a, b,) => { a.forEach(e => displayBLUsers(document.body)) }); 132 | const observer2 = new MutationObserver((a, b) => { a.forEach(e => displayBLUsers2(e.target)); }); 133 | 134 | // Start observing the target node for configured mutations 135 | observer2.observe(targetNode2, config2); 136 | } -------------------------------------------------------------------------------- /extension/scripts/mystuff.js: -------------------------------------------------------------------------------- 1 | console.log('mystuff inject started'); 2 | 3 | // get exId 4 | const exId = document.querySelector('.livescratch-ext').dataset.exId; 5 | 6 | ////////// INJECT UTILS ////////// 7 | 8 | let queryList = []; 9 | function mutationCallback() { 10 | let toDelete = []; 11 | queryList.forEach(query => { 12 | let elem = document.querySelector(query.query); 13 | if (elem && !elem.blSeen) { 14 | if (query.once) { toDelete.push(query); } 15 | else { elem.blSeen = true; } 16 | query.callback(elem); 17 | } 18 | }); 19 | toDelete.forEach(query => { queryList.splice(queryList.indexOf(query), 1); }); 20 | } 21 | let observer = new MutationObserver(mutationCallback); 22 | observer.observe(document.documentElement, { subtree: true, childList: true }); 23 | function getObj(query) { 24 | let obj = document.querySelector(query); 25 | if (obj) { return new Promise(res => { res(obj); }); } 26 | return new Promise(res => { 27 | queryList.push({ query, callback: res, once: true }); 28 | }); 29 | } 30 | function listenForObj(query, callback) { 31 | let obj = document.querySelector(query); 32 | if (obj) { obj.blSeen = true; callback(obj); } 33 | queryList.push({ query, callback, once: false }); 34 | } 35 | 36 | 37 | 38 | 39 | // BLM!!!! 40 | function getBlMyStuff() { 41 | return new Promise((promRes) => { 42 | chrome.runtime.sendMessage(exId, { meta: 'myStuff' }, promRes); 43 | }); 44 | } 45 | 46 | function leaveId(id, div) { 47 | console.log(id, blProjectDivs); 48 | if (id in blProjectDivs) { 49 | document.querySelector('#main-content > div.media-list > ul').insertBefore(blProjectDivs[id], div); 50 | } 51 | div.remove(); 52 | } 53 | 54 | function sendLeave(scratchId, blId) { 55 | blMySTuff.splice(blMySTuff.findIndex(item => (item.scratchId == scratchId)), 1); 56 | if (blId) { 57 | chrome.runtime.sendMessage(exId, { meta: 'leaveLSId', blId }); 58 | } else { 59 | chrome.runtime.sendMessage(exId, { meta: 'leaveScratchId', scratchId }); 60 | } 61 | } 62 | 63 | function sanitize(string) { 64 | string = String(string); 65 | // if(!(_.isString(string))) {return ''} 66 | const map = { 67 | '&': '&', 68 | '<': '<', 69 | '>': '>', 70 | '"': '"', 71 | '\'': ''', 72 | '/': '/', 73 | }; 74 | const reg = /[&<>"'/]/ig; 75 | return string.replace(reg, (match) => (map[match])); 76 | } 77 | 78 | function getbox(blId, title, scratchId, lastModified, lastModBy, projectExists, online) { 79 | scratchId = sanitize(scratchId); 80 | title = sanitize(title); 81 | blId = sanitize(blId); 82 | lastModBy = sanitize(lastModBy); 83 | 84 | let gunkId = Math.random().toString(36).substring(2); 85 | generateActiveUsersPanel(online).then(panel => document.getElementById(gunkId).innerHTML = panel); 86 | 87 | return ` 88 |
89 |
90 | 91 | 92 | 93 |
94 |
95 | ${title} 96 | 97 | 98 | Last modified: ${timeSince(new Date(lastModified))} ago by ${lastModBy} 99 | 100 | 101 | 111 | 112 |
113 | 116 |
`; 117 | } 118 | 119 | 120 | async function generateActiveUsersPanel(active) { 121 | if (active.length > 0) { 122 | active.forEach(username => getUserInfo(username).then(info => document.querySelectorAll(`.${username}`).forEach(elem => elem.style.backgroundImage = `url(${info.pic})`))); 123 | return ` 124 |
125 | 126 | ${active.map(username => 127 | `
`, 128 | ) 129 | .join('\n')} 130 | 131 | 132 | 133 | 134 | 135 | 136 |
137 | `; 138 | 139 | } else { 140 | return ''; 141 | } 142 | } 143 | 144 | let pageCss = ` 145 | 146 | .onlineBubble{ 147 | height:26px; 148 | width:26px; 149 | outline:solid 3px rgb(88, 193, 152); 150 | border-radius:10px; 151 | background-size:100% auto; 152 | text-shadow:none; 153 | 154 | margin-right:8px; 155 | 156 | } 157 | .onlineBubble::after{ 158 | background:black; 159 | width:100px; 160 | height:30px; 161 | padding-top:0; 162 | padding-bottom:0; 163 | translate: -25% 0; 164 | } 165 | 166 | .onlineBubble:hover::after { 167 | opacity: 100%; 168 | } 169 | 170 | .onlineBubble::after { 171 | content: var(--bubbleUsername); 172 | background: rgb(88, 193, 152); 173 | color: white; 174 | padding: 0 6px; 175 | border-radius: 5px; 176 | transform: translate(0px, -23px); 177 | display: inline-block; 178 | width: fit-content; 179 | height: fit-content; 180 | opacity: 0%; 181 | transition: 0.2s opacity; 182 | 183 | } 184 | 185 | 186 | .seeInsideContainer, .activeContainer{ 187 | display:flex; 188 | flex-flow:row; 189 | align-items:flex-end; 190 | // gap:8px; 191 | } 192 | 193 | .activeContainer{ 194 | align-items:center; 195 | } 196 | 197 | .tailText{ 198 | color:grey 199 | } 200 | 201 | 202 | .media-control-edit{ 203 | display:flex !important; 204 | flex-flow:row; 205 | align-items:center; 206 | flex-grow:0; 207 | width:fit-content; 208 | 209 | } 210 | 211 | .media-info-item.date.shortDateFormat{ 212 | width:200%; 213 | } 214 | `; 215 | 216 | 217 | function injectStyle() { 218 | const style = document.createElement('style'); 219 | style.textContent = pageCss; 220 | document.head.append(style); 221 | } 222 | injectStyle(); 223 | 224 | 225 | // https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site 226 | function timeSince(date) { 227 | 228 | var seconds = Math.floor((new Date() - date) / 1000); 229 | if (seconds < 0) { return 'zero seconds'; } 230 | 231 | var interval = seconds / 31536000; 232 | 233 | if (interval > 1) { 234 | return Math.floor(interval) + ' years'; 235 | } 236 | interval = seconds / 2592000; 237 | if (interval > 1) { 238 | return Math.floor(interval) + ' months'; 239 | } 240 | interval = seconds / 86400; 241 | if (interval > 1) { 242 | return Math.floor(interval) + ' days'; 243 | } 244 | interval = seconds / 3600; 245 | if (interval > 1) { 246 | return Math.floor(interval) + ' hours'; 247 | } 248 | interval = seconds / 60; 249 | if (interval > 1) { 250 | return Math.floor(interval) + ' minutes'; 251 | } 252 | return Math.floor(seconds) + ' seconds'; 253 | } 254 | 255 | function getId(listItem) { 256 | return listItem.children[0].children[0].children[0].getAttribute('href').split('/')[2]; 257 | } 258 | 259 | 260 | let oldAttrs = {}; 261 | async function convertToLivescratch(listItem, projectObj) { 262 | let atts = {}; 263 | atts.color = listItem.children[0].children[1].children[0].children[0].style.color; 264 | listItem.children[0].children[1].children[0].children[0].style.color = 'rgb(88, 193, 152)'; 265 | listItem.children[0].children[2].children[0].children[0].style.color = 'rgb(88, 193, 152)'; 266 | 267 | atts.buttonText = listItem.children[0].children[2].children[0].children[0].innerText; 268 | listItem.children[0].children[2].children[0].children[0].innerText = 'Unlink'; 269 | listItem.children[0].children[2].children[0].children[0].onclick = (e) => {cleanseOfLivescratchness(projectObj.scratchId, listItem); sendLeave(projectObj.scratchId, projectObj.blId); e.stopPropagation(); }; 270 | atts.title = listItem.children[0].children[1].children[0].children[0].innerText; 271 | listItem.children[0].children[1].children[0].children[0].innerText = projectObj.title; 272 | 273 | atts.modified = listItem.children[0].children[1].children[1].innerText; 274 | listItem.children[0].children[1].children[1].innerText = `Last modified: ${timeSince(new Date(projectObj.lastTime))} ago by ${projectObj.lastUser}`; 275 | 276 | oldAttrs[projectObj.scratchId] = atts; 277 | 278 | let seeInside = listItem.querySelector('a span'); 279 | let activeUsersPanel = await generateActiveUsersPanel(projectObj.online); 280 | seeInside.insertAdjacentHTML('afterend', activeUsersPanel); 281 | 282 | 283 | } 284 | function cleanseOfLivescratchness(scratchId, listItem) { 285 | let atts = oldAttrs[scratchId]; 286 | if (!atts) { return; } 287 | listItem.children[0].children[1].children[0].children[0].style.color = atts.color; 288 | listItem.children[0].children[2].children[0].children[0].style.color = atts.color; 289 | listItem.children[0].children[2].children[0].children[0].innerText = atts.buttonText; 290 | // listItem.children[0].children[2].children[0].children[0].onclick = ()=>{alert('yi')} 291 | listItem.children[0].children[1].children[0].children[0].innerText = atts.title; 292 | listItem.children[0].children[1].children[1].innerText = atts.modified; 293 | } 294 | 295 | function addProject(projectObj, projectExists) { 296 | let newBox = document.createElement('li'); 297 | newBox.innerHTML = getbox(projectObj.blId, projectObj.title, projectObj.scratchId, projectObj.lastTime, projectObj.lastUser, projectExists, projectObj.online); 298 | document.querySelector('ul.media-list').insertBefore(newBox, document.querySelector('ul.media-list').firstChild); 299 | } 300 | 301 | usersCache = {}; 302 | 303 | async function getUserInfo(username) { 304 | if (!username) { return; } 305 | if (username?.toLowerCase() in usersCache && usersCache[username?.toLowerCase()]?.pk) { return usersCache[username?.toLowerCase()]; } 306 | 307 | let res; 308 | try { 309 | res = await (await fetch('https://scratch.mit.edu/site-api/users/all/' + username?.toLowerCase())).json(); 310 | } catch (e) { 311 | return null; 312 | } 313 | if (!res) { 314 | return null; 315 | } 316 | 317 | let user = res.user; 318 | user = getWithPic(user); 319 | usersCache[user.username.toLowerCase()] = user; 320 | return user; 321 | } 322 | function getWithPic(user) { 323 | user.pic = `https://uploads.scratch.mit.edu/get_image/user/${user.pk}_60x60.png`; 324 | return user; 325 | } 326 | 327 | 328 | ////////// RUN ON START! /////////// 329 | 330 | let blMySTuff; 331 | let blMyStuffMap = {}; 332 | let blProjectDivs = {}; 333 | let projectLoadFailed = false; 334 | async function onTabLoad() { 335 | blMySTuff = await getBlMyStuff(); 336 | if (blMySTuff?.noauth) { projectLoadFailed = true; return false; } 337 | listenForObj('ul.media-list', (list) => { 338 | if (!document.querySelector('#tabs > li.first.active')) { return; } // return if "all projects" not selected 339 | blMyStuffMap = {}; 340 | blMySTuff.forEach(projObj => { blMyStuffMap[projObj.scratchId] = projObj; }); 341 | let toDelete = []; 342 | for (let child of list.children) { 343 | let scratchId = getId(child); 344 | let livescratchProject = blMyStuffMap[scratchId]; 345 | if (livescratchProject) { 346 | if (Date.now() - livescratchProject.lastTime < 1000 * 60 * 60 * 2) { // if project was edited less than 2 hours ago 347 | toDelete.push(child); 348 | blProjectDivs[scratchId] = child; 349 | } else { 350 | convertToLivescratch(child, livescratchProject); 351 | delete blMyStuffMap[scratchId]; 352 | } 353 | } 354 | } 355 | toDelete.forEach(elem => { elem.remove(); }); 356 | let leftOver = Object.values(blMyStuffMap); 357 | leftOver.sort((a, b) => { b.lastTime - a.lastTime; }); 358 | for (let projObj of leftOver) { 359 | console.log(projObj.scratchId); 360 | addProject(projObj, projObj.scratchId in blProjectDivs); 361 | } 362 | }); 363 | } 364 | 365 | 366 | chrome.runtime.sendMessage(exId, { meta: 'getUsernamePlus' }, async (userData) => { 367 | if (!userData.currentBlToken) { 368 | 369 | let newVerified = false; 370 | addStartVerificationCallback(() => { 371 | document.querySelector('#verifying')?.remove(); 372 | document.querySelector('#unverified')?.remove(); 373 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
Livescratch is verifying your account ...
'); 374 | }); 375 | addEndVerificationCallback(async (success, message) => { 376 | document.querySelector('#verifying')?.remove(); 377 | document.querySelector('#unverified')?.remove(); 378 | if (success) { 379 | newVerified = true; 380 | removeHideLivescratchButton(); 381 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
✅ You\'re verified!
'); 382 | if (projectLoadFailed) { onTabLoad(); } 383 | removeHideLivescratchButton(); 384 | setTimeout(() => { document.querySelector('#blSuccess').remove(); }, 1000 * 2); 385 | } else { 386 | let error = await chrome.runtime.sendMessage(exId,{meta:'getVerifyError'}); 387 | if(error=='no cloud') { 388 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
⚠️ Livescratch could not verify your account because the cloud data \'set\' action failed. Scratch\'s cloud data servers might be down, causing this issue. Click \'hide verify\' below to silence this message.'); 389 | } else { 390 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
⚠️ Livescratch could not verify your account. Reload the tab in a few seconds. If this issue continues, contact @WaakulSee Error Msg'); 391 | } 392 | 393 | } 394 | }); 395 | 396 | 397 | 398 | 399 | chrome.runtime.sendMessage(exId, { meta: 'verifying' }, (verifying) => { 400 | console.log('vf',verifying); 401 | console.log('verifying',verifying); 402 | if (verifying=='nocon'){ 403 | // defaultAddHideLivescratchButton() 404 | let apiURL = ''; 405 | chrome.runtime.sendMessage(exId, {meta: 'getAPI-URL'}, (response) => { 406 | apiURL = response.apiURL; 407 | }); 408 | document.querySelector('.box-head').insertAdjacentHTML('afterend', `
⚠️ Cant connect to livescratch servers at ${apiURL} Check Uptime or Dont show this message again
`); 409 | } 410 | else if (verifying) { 411 | document.querySelector('#verifying')?.remove(); 412 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
Livescratch is verifying your account ...
'); 413 | } else { 414 | if (newVerified) { return; } 415 | document.querySelector('.box-head').insertAdjacentHTML('afterend', '
⚠️ Livescratch could not verify your account. Reload the tab in a few seconds. If this issue continues, contact @Waakul
'); 416 | } 417 | }); 418 | 419 | 420 | } 421 | }); 422 | 423 | 424 | function addStartVerificationCallback(cb) { 425 | chrome.runtime.sendMessage(exId, { meta: 'startVerifyCallback' }, cb); 426 | } 427 | function addEndVerificationCallback(cb) { 428 | chrome.runtime.sendMessage(exId, { meta: 'endVerifyCallback' }, cb); 429 | } 430 | 431 | onTabLoad(); 432 | 433 | 434 | var BLVon = false; 435 | function addHideLivescratchButton(on) { 436 | window.BLVon = on; 437 | let innerds = on ? 'Hide Verify ^' : 'Show Verify'; 438 | document.getElementById('hideBLButton')?.remove(); 439 | document.querySelector('#main-content > div.action-bar.scroll > div > div').insertAdjacentHTML('afterend', ` 440 | ${innerds}`); 442 | 443 | document.getElementById('blBannerCSS')?.remove(); 444 | document.head.insertAdjacentHTML('afterbegin', on ? '' : ''); 445 | 446 | } 447 | function toggleHideLivescratch() { 448 | addHideLivescratchButton(!BLVon); 449 | } 450 | let actuallyShown = false; 451 | function defaultAddHideLivescratchButton(hideButton) { 452 | if(!hideButton) {actuallyShown = true;} 453 | chrome.runtime.sendMessage(exId, { meta: 'getShowVerifyError' }, answer => {addHideLivescratchButton(answer); 454 | if (hideButton && !actuallyShown) { removeHideLivescratchButton(); } 455 | }); 456 | 457 | } 458 | function removeHideLivescratchButton() { 459 | document.getElementById('hideBLButton')?.remove(); 460 | } 461 | 462 | defaultAddHideLivescratchButton(true); // just to add styles 463 | -------------------------------------------------------------------------------- /extension/scripts/verify.js: -------------------------------------------------------------------------------- 1 | function askVerify() { 2 | chrome.runtime.sendMessage({ meta: 'verify?' }, async (response) => { 3 | if(!response) {return;} 4 | let res = await setCloudTempCode(response.code, response.project); 5 | chrome.runtime.sendMessage({ meta: 'setCloud', res }); 6 | }); 7 | } 8 | askVerify(); 9 | 10 | async function setCloudVar(value, AUTH_PROJECTID) { 11 | const user = await chrome.runtime.sendMessage({ meta: 'getUsername' }); 12 | if(user=='*') {return {err:'livescratch thinks you are logged out'};} 13 | const connection = new WebSocket('wss://clouddata.scratch.mit.edu'); 14 | 15 | let setAndClose = new Promise((res) => { 16 | try{ 17 | 18 | connection.onerror = function (error) { 19 | console.error('WebSocket error:', error); 20 | connection.close(); 21 | res({err:error}); 22 | }; 23 | 24 | connection.onopen = async () => { 25 | connection.send( 26 | JSON.stringify({ method: 'handshake', project_id: AUTH_PROJECTID, user }) + '\n'); 27 | await new Promise((r) => setTimeout(r, 100)); 28 | connection.send( 29 | JSON.stringify({ 30 | value: value.toString(), 31 | name: '☁ verify', 32 | method: 'set', 33 | project_id: AUTH_PROJECTID, 34 | user, 35 | }) + '\n', 36 | ); 37 | connection.close(); 38 | res({ok:true}); 39 | return {ok:true}; 40 | }; 41 | } catch(e) {res({err:e});} 42 | }); 43 | return await setAndClose; 44 | } 45 | 46 | 47 | async function setCloudTempCode(code, projectInfo) { 48 | let response = await setCloudVar(code, projectInfo); 49 | if(response.err instanceof Error) {response.err = response.err.stack;} 50 | return response; 51 | } 52 | 53 | 54 | // observe login 55 | 56 | const targetNode = document.querySelector('.registrationLink')?.parentNode?.parentNode; 57 | 58 | if (targetNode) { // only add the listener on the logged out page 59 | // Options for the observer (which mutations to observe) 60 | const config = { attributes: true, childList: true, subtree: true }; 61 | 62 | // Callback function to execute when mutations are observed 63 | const callback = (mutationList, observer) => { 64 | for (const mutation of mutationList) { 65 | if (mutation.addedNodes?.[0]?.classList.contains('account-nav')) { 66 | console.log('ls login detected'); 67 | askVerify(); 68 | } 69 | } 70 | }; 71 | 72 | // Create an observer instance linked to the callback function 73 | const observer = new MutationObserver(callback); 74 | 75 | // Start observing the target node for configured mutations 76 | observer.observe(targetNode, config); 77 | } -------------------------------------------------------------------------------- /extension/sounds/ping.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlockliveScratch/Blocklive/b16adf130e0bf4acc84e22a8aaf677dd4a8f580b/extension/sounds/ping.mp3 --------------------------------------------------------------------------------