Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b47cb0d35 | ||
|
|
9804771a5b | ||
|
|
d9abeb7fb1 | ||
|
|
81b4162117 | ||
|
|
8d60aae65c | ||
|
|
7d255fe687 | ||
|
|
b33e2710c3 | ||
|
|
26e31f1885 | ||
|
|
7f38444707 | ||
|
|
2ce1fc073d | ||
|
|
42287395fe | ||
|
|
98261207d6 | ||
|
|
590ae01055 | ||
|
|
2d5d23b104 | ||
|
|
5bebe072b2 | ||
|
|
9f37d3faf1 | ||
|
|
68f71c9d6c | ||
|
|
0b77b0516d |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
echo '-------'
|
||||
echo 'Starting Database Restore'
|
||||
echo '-------'
|
||||
|
||||
#get Latest database backup
|
||||
|
||||
# Unzip File
|
||||
# gzip -dk file.gz
|
||||
|
||||
source ~/.env
|
||||
|
||||
echo "$PROD_DB_PASS"
|
||||
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
|
||||
mkdir -p $BACKUPDIR
|
||||
cd $BACKUPDIR
|
||||
|
||||
# -t sort by modification time, newest first
|
||||
# -A --almost-all, do not list implied . and ..
|
||||
LASTZIPPEDFILE=$(ls -At *.gz | head -n1)
|
||||
|
||||
# -k keep file after unzip
|
||||
# -d Decompress
|
||||
# -v verbose
|
||||
echo "Unzipping $LASTZIPPEDFILE"
|
||||
gunzip -dkv $LASTZIPPEDFILE
|
||||
|
||||
BACKUPFILE=$(ls -At *.sql | head -n1)
|
||||
|
||||
#Fix to replace incompatible DB type
|
||||
echo "Updating table name in -> $BACKUPFILE"
|
||||
#sed -i $BACKUPFILE -e 's/utf8mb4_0900_ai_ci/utf8mb4_unicode_ci/g'
|
||||
|
||||
#Fix encoding for dev DB and exclude system tables
|
||||
# sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_general_ci/g' $BACKUPFILE
|
||||
# sed -r '/INSERT INTO `(sys|mysql)`/d' $BACKUPFILE > $BACKUPFILE
|
||||
|
||||
#echo "Removing and syncing static files"
|
||||
#rm -r /home/mab/ss/staticFiles/*
|
||||
#rsync -e 'ssh -p 13328' -hazC --update [email protected]:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
|
||||
echo "Updating Database"
|
||||
mysql -u root --password="$PROD_DB_PASS" < $BACKUPFILE
|
||||
|
||||
## Optimize Database Tables
|
||||
# mysqlcheck --all-databases
|
||||
mysqlcheck --all-databases -o -u root --password="$PROD_DB_PASS" #--silent
|
||||
# mysqlcheck --all-databases --auto-repair
|
||||
# mysqlcheck --all-databases --analyze
|
||||
|
||||
# Fix an issues with DB after messing around with it
|
||||
mysql_upgrade -u root --password="$PROD_DB_PASS"
|
||||
|
||||
#clean up extracted and modified SQL dumps
|
||||
rm *.sql
|
||||
|
||||
|
||||
|
||||
echo '-------'
|
||||
echo "Applied Prod database to Dev. LastFile: $BACKUPFILE"
|
||||
echo '-------'
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo '-------'
|
||||
echo 'Starting Database Restore'
|
||||
echo '-------'
|
||||
@@ -12,7 +10,7 @@ echo '-------'
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
#DEVDBPASS="Crama!Lama*Jamma###88383!!!!!345345956245i"
|
||||
#DEVDBPASS="RootPass1234!"
|
||||
DEVDBPASS="ReallySecureRootPass123!"
|
||||
DEVDBPASS="LetsKeepOut69FatHorses!"
|
||||
# LazaLinga&33Can't!Do!That34
|
||||
|
||||
cd $BACKUPDIR
|
||||
@@ -39,14 +37,14 @@ sed -r '/INSERT INTO `(sys|mysql)`/d' $BACKUPFILE > $BACKUPFILE
|
||||
|
||||
echo "Removing and syncing static files"
|
||||
rm -r /home/mab/ss/staticFiles/*
|
||||
rsync -e 'ssh -p 13328' -hazC --update mab@solidscribe.com:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
rsync -e 'ssh -p 13328' -hazC --update mab@45.79.41.75:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
|
||||
echo "Updating Database"
|
||||
mysql -u root --password="$DEVDBPASS" < $BACKUPFILE
|
||||
|
||||
## Optimize Database Tables
|
||||
# mysqlcheck --all-databases
|
||||
mysqlcheck --all-databases -o -u root --password="$DEVDBPASS" --silent
|
||||
mysqlcheck --all-databases -o -u root --password="$DEVDBPASS" #--silent
|
||||
# mysqlcheck --all-databases --auto-repair
|
||||
# mysqlcheck --all-databases --analyze
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Take all variables in .env and turn them into local variables for this script
|
||||
source ~/.env
|
||||
source /home/mab/.env
|
||||
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
# BACKUPDIR defined in .env
|
||||
|
||||
mkdir -p $BACKUPDIR
|
||||
cd $BACKUPDIR
|
||||
|
||||
NOW=$(date +"%Y-%m-%d_%H-%M")
|
||||
ssh [email protected] -p 13328 "mysqldump --all-databases --single-transaction --user root -p$PROD_DB_PASS" > "backup-$NOW.sql"
|
||||
mysqldump --all-databases --single-transaction --user root --password="$PROD_DB_PASS" > "backup-$NOW.sql"
|
||||
gzip "backup-$NOW.sql"
|
||||
|
||||
# cp "backup-$NOW.sql" "/mnt/Windows Data/DatabaseBackups/backup-$NOW.sql"
|
||||
@@ -17,7 +17,7 @@ gzip "backup-$NOW.sql"
|
||||
echo "Database Backup Complete on $NOW"
|
||||
|
||||
# Delete all but last 8 files
|
||||
ls -tp | grep -v '/$' | tail -n +9 | tr '\n' '\0' | xargs -0 rm --
|
||||
ls -tp | grep -v '/$' | tail -n +9 | tr '\n' '\0' | xargs -0 rm -f --
|
||||
|
||||
##
|
||||
# Restore DB
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
#
|
||||
# Push built release files to production server
|
||||
#
|
||||
|
||||
echo -e "\e[32m\nStarting Build. \n\e[0m"
|
||||
|
||||
# Build out new release
|
||||
cd client
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# only compress client/dist and server with node_modules
|
||||
echo -e "\e[32m\nCompressing client and server code... \n\e[0m"
|
||||
tar -czf release.tar.gz server node_modules client/dist staticFiles/assets
|
||||
|
||||
#send compressed release to remote machine
|
||||
echo -e "\e[32m\nMoving compressed release to production... \n\e[0m"
|
||||
rsync -e 'ssh -p 13328' -havzC --update release.tar.gz [email protected]:/home/mab/pi/
|
||||
|
||||
# Remove Release from local after its been uploaded
|
||||
rm release.tar.gz
|
||||
|
||||
#uncompress release on server
|
||||
echo -e "\e[32m\nExtracting release on production... \n\e[0m"
|
||||
ssh [email protected] -p 13328 "cd /home/mab/pi/; rm -r server node_modules client; tar -xzf *.tar.gz --overwrite; rm *.tar.gz; pm2 reload all"
|
||||
|
||||
#Congratulate how awesome you are
|
||||
echo -e "\e[32m\nRelease Complete! Nice Work! \n\e[0m"
|
||||
@@ -1,26 +1,28 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# Log files
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local dev TLS certs (mkcert)
|
||||
*.pem
|
||||
certs/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
# client
|
||||
# Vue 3 + Vite
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Encrypted notes and scratch pad" />
|
||||
|
||||
<!-- PWA. The manifest is a real static file: it used to be injected at
|
||||
runtime as a base64 data: URI from App.vue, with the production origin
|
||||
hardcoded, which broke icons and scope outside production. -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/icons/logo.svg" />
|
||||
|
||||
<!-- iOS has no share-target support, but still installs to the home screen.
|
||||
The apple-touch icon must be the opaque variant -- iOS composites
|
||||
transparent PNGs onto black and the dark half of the mark vanishes. -->
|
||||
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-180.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Solid Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "solidscribe",
|
||||
"version": "0.1.0",
|
||||
"name": "solidscribe-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"axios": "^1.1.3",
|
||||
"core-js": "^3.6.5",
|
||||
"es6-promise": "^4.2.8",
|
||||
"chart.js": "^4.6.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fomantic-ui-css": "^2.9.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-chartjs": "^5.0.1",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuedraggable": "^2.24.3",
|
||||
"vuex": "^3.4.0"
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-router": "^4.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-plugin-vuex": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 132.29166 132.29167" width="512" height="512">
|
||||
<rect x="0" y="0" width="132.29166" height="132.29167" fill="#ffffff"/>
|
||||
<g transform="translate(13.229166,13.229167) scale(0.8)">
|
||||
<g transform="translate(0,-164.70832)">
|
||||
<path id="path3813-4" d="m 56.22733,165.36641 -55.56249926,15.875 8e-7,63.5 47.62499846,11.90625 v 27.78125 l -47.76066333,-13.9757 0.13566407,10.00695 55.56249926,15.875 v -47.625 l -47.6249985,-11.90625 -8e-7,-47.625 47.7606633,-13.94121 c 0.135664,-2.30629 -0.135664,-9.87129 -0.135664,-9.87129 z" style="fill:#0f7425;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
<path id="path4563" d="m 20.508581,220.92891 c 15.265814,-14.23899 27.809717,-7.68002 39.687499,3.96875 v -7.9375 C 51.75093,200.8366 37.512584,206.01499 20.508581,205.05391 Z" style="fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path4563-6" d="m 111.78985,220.92891 c -15.265834,-14.23899 -27.809737,-7.68002 -39.68752,3.96875 v -7.9375 c 8.445151,-16.12356 22.683497,-10.94517 39.68752,-11.90625 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path3813-4-2" d="m 76.07108,165.36641 55.5625,15.875 v 63.5 l -47.625,11.90625 v 27.78125 l 47.76067,-13.9757 -0.13567,10.00695 -55.5625,15.875 v -47.625 l 47.625,-11.90626 V 189.17891 L 75.93542,175.2377 c -0.13567,-2.30629 0.13566,-9.87129 0.13566,-9.87129 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.52916676;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 132.29166 132.29167" width="512" height="512">
|
||||
<g transform="translate(0,-164.70832)">
|
||||
<path id="path3813-4" d="m 56.22733,165.36641 -55.56249926,15.875 8e-7,63.5 47.62499846,11.90625 v 27.78125 l -47.76066333,-13.9757 0.13566407,10.00695 55.56249926,15.875 v -47.625 l -47.6249985,-11.90625 -8e-7,-47.625 47.7606633,-13.94121 c 0.135664,-2.30629 -0.135664,-9.87129 -0.135664,-9.87129 z" style="fill:#0f7425;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
<path id="path4563" d="m 20.508581,220.92891 c 15.265814,-14.23899 27.809717,-7.68002 39.687499,3.96875 v -7.9375 C 51.75093,200.8366 37.512584,206.01499 20.508581,205.05391 Z" style="fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path4563-6" d="m 111.78985,220.92891 c -15.265834,-14.23899 -27.809737,-7.68002 -39.68752,3.96875 v -7.9375 c 8.445151,-16.12356 22.683497,-10.94517 39.68752,-11.90625 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path3813-4-2" d="m 76.07108,165.36641 55.5625,15.875 v 63.5 l -47.625,11.90625 v 27.78125 l 47.76067,-13.9757 -0.13567,10.00695 -55.5625,15.875 v -47.625 l 47.625,-11.90626 V 189.17891 L 75.93542,175.2377 c -0.13567,-2.30629 0.13566,-9.87129 0.13566,-9.87129 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.52916676;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -1,69 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<link rel="icon" href="/api/static/assets/favicon.ico" type="image/ico"/>
|
||||
<link rel="shortcut icon" href="/api/static/assets/favicon.ico" type="image/x-icon"/>
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
<link rel="manifest" href="/api/static/assets/manifest.json">
|
||||
|
||||
<title>Solid Scribe - An easy, encrypted Note App</title>
|
||||
<!-- <title><%= htmlWebpackPlugin.options.title %></title> -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but Solid Scribe doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app">
|
||||
<!-- placeholder data for scrapers with no JS -->
|
||||
<style>
|
||||
body {
|
||||
background-color: #212221;
|
||||
color: #aeaeae;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
.centered {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.logo {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
}
|
||||
.scrape-info {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="centered">
|
||||
<img class="logo" src="/api/static/assets/logo.svg" alt="Solid Scribe Logo - if you can read this your connection is really slow">
|
||||
<h1>Solid Scribe</h1>
|
||||
<h3>An easy, encrypted Note App</h3>
|
||||
<h4>Loading...</h4>
|
||||
</div>
|
||||
|
||||
<div class="scrape-info">
|
||||
<h1>Solid Scribe</h1>
|
||||
<h2>A note application that respects your privacy.</h2>
|
||||
<p>Take notes with a clean editor that works on desktop or mobile.</p>
|
||||
<p>Search notes, links and files to find what you need.</p>
|
||||
<p>Accessable everywhere.</p>
|
||||
<p>Categorize notes with tags.</p>
|
||||
<p>Share data with fellow users.</p>
|
||||
<p>Encrypt notes for additional security.</p>
|
||||
<b>This site requires Javascipt to run.</b>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Solid Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Encrypted notes and scratch pad",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Scratch Pad",
|
||||
"short_name": "Scratch",
|
||||
"url": "/quick",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Files & Links",
|
||||
"short_name": "Files",
|
||||
"url": "/attachments",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="app" :class="{ 'night-mode':($store.getters.getIsNightMode == 2) }">
|
||||
|
||||
<div class="ui container" v-if="showFakeSite">
|
||||
<!-- <div class="ui container" v-if="showFakeSite">
|
||||
<div class="ui basic very padded segment">
|
||||
<div class="ui inverted red segment">
|
||||
<h1>WARNING - False site detected</h1>
|
||||
@@ -11,7 +11,7 @@
|
||||
<h2>You will be redirected to the correct domain in {{redirectSeconds}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="auth-block" v-if="requireAuth">
|
||||
<div class="ui raised inverted segment">
|
||||
@@ -51,19 +51,24 @@
|
||||
|
||||
<global-notification />
|
||||
|
||||
<update-prompt />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
// import io from 'socket.io-client'
|
||||
import axios from 'axios'
|
||||
import GlobalSiteMenu from '@/components/GlobalSiteMenu.vue'
|
||||
import GlobalNotification from '@/components/GlobalNotificationComponent.vue'
|
||||
import UpdatePrompt from '@/components/UpdatePrompt.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
'global-site-menu': require('@/components/GlobalSiteMenu.vue').default,
|
||||
'global-notification':require('@/components/GlobalNotificationComponent.vue').default,
|
||||
'global-site-menu': GlobalSiteMenu,
|
||||
'global-notification': GlobalNotification,
|
||||
'update-prompt': UpdatePrompt,
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -172,16 +177,18 @@ export default {
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
const isDev = process.env['NODE_ENV'] == 'development'
|
||||
if(window.location.hostname.toLowerCase().replace('www.','') != "solidscribe.com" && !isDev){
|
||||
this.showFakeSite = true
|
||||
setInterval(() => {
|
||||
this.redirectSeconds--
|
||||
if(this.redirectSeconds == 0){
|
||||
window.location.href = 'https://www.solidscribe.com'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
// Vite does not shim node's `process` into the browser bundle the way
|
||||
// webpack/vue-cli did; import.meta.env.DEV is the equivalent.
|
||||
const isDev = import.meta.env.DEV
|
||||
// if(window.location.hostname.toLowerCase().replace('www.','') != "solidscribe.com" && !isDev){
|
||||
// this.showFakeSite = true
|
||||
// setInterval(() => {
|
||||
// this.redirectSeconds--
|
||||
// if(this.redirectSeconds == 0){
|
||||
// window.location.href = 'https://www.solidscribe.com'
|
||||
// }
|
||||
// }, 1000)
|
||||
// }
|
||||
|
||||
//Update totals for entire app on event
|
||||
this.$io.on('update_counts', () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Vue from 'vue'
|
||||
const EventBus = new Vue()
|
||||
import mitt from 'mitt'
|
||||
|
||||
Object.defineProperties(Vue.prototype, {
|
||||
$bus: {
|
||||
get: () => EventBus
|
||||
}
|
||||
})
|
||||
const emitter = mitt()
|
||||
|
||||
export default EventBus;
|
||||
// Preserves the Vue 2 $on/$emit/$off API used throughout the codebase
|
||||
const EventBus = {
|
||||
$on: (...args) => emitter.on(...args),
|
||||
$off: (...args) => emitter.off(...args),
|
||||
$emit: (...args) => emitter.emit(...args),
|
||||
}
|
||||
|
||||
export default EventBus
|
||||
@@ -1,5 +1,3 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
const helpers = {}
|
||||
|
||||
helpers.timeAgo = (time) => {
|
||||
@@ -62,10 +60,9 @@ helpers.timeAgo = (time) => {
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
install () {
|
||||
Vue.helpers = helpers
|
||||
Vue.prototype.$helpers = helpers
|
||||
install(app) {
|
||||
app.config.globalProperties.$helpers = helpers
|
||||
}
|
||||
}
|
||||
|
||||
Vue.use(plugin)
|
||||
export default plugin
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Launch page preference.
|
||||
//
|
||||
// The app used to inject its manifest at runtime with
|
||||
// `start_url: window.location.href`, so installing from /metrictrack made the
|
||||
// installed app always open there. That mattered: not everyone wants the notes
|
||||
// page as their home.
|
||||
//
|
||||
// A static manifest can't carry a per-install start_url, and we need a static
|
||||
// same-origin manifest for share_target to register at all (a data: URI
|
||||
// manifest has an opaque origin, so scope resolution fails). So the preference
|
||||
// moves in-app: the manifest's start_url is "/", and we redirect from there.
|
||||
//
|
||||
// This is also strictly better than the old behaviour -- the old one froze the
|
||||
// home page at install time and could only be changed by reinstalling. This one
|
||||
// is editable in Settings.
|
||||
//
|
||||
|
||||
const LAUNCH_KEY = 'launchPage'
|
||||
const DEFAULT_LAUNCH = '/notes'
|
||||
|
||||
// Pages that make sense as a home. Anything else (a specific note, the share
|
||||
// target, login) would be a bad place to land every time.
|
||||
export const LAUNCH_OPTIONS = [
|
||||
{ path: '/notes', label: 'Notes' },
|
||||
{ path: '/overview', label: 'Overview' },
|
||||
{ path: '/quick', label: 'Scratch Pad' },
|
||||
{ path: '/metrictrack', label: 'Metric Tracking' },
|
||||
{ path: '/attachments', label: 'Files & Links' },
|
||||
]
|
||||
|
||||
export function getLaunchPage(){
|
||||
const stored = localStorage.getItem(LAUNCH_KEY)
|
||||
const valid = LAUNCH_OPTIONS.some(option => option.path === stored)
|
||||
return valid ? stored : DEFAULT_LAUNCH
|
||||
}
|
||||
|
||||
export function setLaunchPage(path){
|
||||
if(!LAUNCH_OPTIONS.some(option => option.path === path)){ return }
|
||||
localStorage.setItem(LAUNCH_KEY, path)
|
||||
}
|
||||
|
||||
export function isStandalone(){
|
||||
return window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true
|
||||
}
|
||||
|
||||
//
|
||||
// Capture the page the user installed from, matching the old behaviour: if they
|
||||
// install while on /metrictrack, that becomes their launch page. Only set as a
|
||||
// default -- an explicit choice in Settings is never overwritten.
|
||||
//
|
||||
export function watchForInstall(){
|
||||
window.addEventListener('appinstalled', () => {
|
||||
if(localStorage.getItem(LAUNCH_KEY)){ return }
|
||||
|
||||
const path = window.location.pathname
|
||||
if(LAUNCH_OPTIONS.some(option => option.path === path)){
|
||||
localStorage.setItem(LAUNCH_KEY, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// "/" is a shim for installed launches. Redirect only when running standalone,
|
||||
// so the marketing home page still works normally in a browser tab.
|
||||
//
|
||||
export function installLaunchRedirect(router){
|
||||
|
||||
watchForInstall()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
||||
// Only the very first navigation of a standalone launch
|
||||
if(to.path === '/' && from.matched.length === 0 && isStandalone()){
|
||||
return next(getLaunchPage())
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -179,7 +179,7 @@ div.ui.basic.green.label {
|
||||
}
|
||||
/*Overwrites for modifiable theme color */
|
||||
i.green.icon.icon.icon.icon, i.green.icon.icon.icon.icon.icon {
|
||||
color: var(--main-accent);
|
||||
color: var(--main-accent) !important;
|
||||
}
|
||||
.button {
|
||||
box-shadow: 2px 2px 4px -2px rgba(40, 40, 40, 0.89) !important;
|
||||
@@ -1001,3 +1001,8 @@ padding-right: 10px;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
||||
i.outline {
|
||||
outline-style: none;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -18,12 +18,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
name: 'FileUploadButton',
|
||||
props: [ 'noteId' ],
|
||||
components: {
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -369,14 +369,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'search-input': require('@/components/SearchInput.vue').default,
|
||||
'counter':require('@/components/AnimatedCounterComponent.vue').default,
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
||||
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -393,7 +394,7 @@
|
||||
beforeMount(){
|
||||
window.addEventListener('resize', this.resizeEventHandler)
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
window.removeEventListener('resize', this.resizeEventHandler)
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
@@ -153,8 +153,10 @@
|
||||
this.$io.emit('user_connect', token)
|
||||
localStorage.setItem('loginToken', token)
|
||||
|
||||
//Redirect user to notes section after login
|
||||
this.$router.push('/notes')
|
||||
//Redirect user to notes section after login, or back to
|
||||
//wherever they were headed (e.g. an Android share waiting to
|
||||
//be saved -- see ShareTargetPage.vue)
|
||||
this.$router.push(this.$route.query.redirect || '/notes')
|
||||
}
|
||||
},
|
||||
register(){
|
||||
|
||||
@@ -12,18 +12,6 @@
|
||||
width="500">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
style="display:inline"
|
||||
transform="translate(0,-164.70832)"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="line-graph-holder">
|
||||
<Line v-if="hasData" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="no-data">No data for the selected range.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||
|
||||
// Categorical slots, validated for both of this app's surfaces (#fff / #000)
|
||||
// with scripts/validate_palette.js. Assigned by a field's stable position,
|
||||
// never by its position in a particular graph, so toggling fields on and off
|
||||
// never repaints the ones that stay.
|
||||
const SERIES = {
|
||||
light: ['#2a78d6','#eb6834','#1baf7a','#eda100','#e87ba4','#008300','#4a3aa7','#e34948'],
|
||||
dark: ['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'],
|
||||
}
|
||||
|
||||
// A field can carry an explicit Fomantic colour name chosen by the user; that
|
||||
// is their intent and it matches the field's icon elsewhere on the page, so it
|
||||
// wins over the generated slot.
|
||||
const FOMANTIC = {
|
||||
red:'#DB2828', orange:'#F2711C', yellow:'#FBBD08', olive:'#B5CC18',
|
||||
green:'#21BA45', teal:'#00B5AD', blue:'#2185D0', violet:'#6435C9',
|
||||
purple:'#A333C8', pink:'#E03997', brown:'#A5673F', grey:'#767676',
|
||||
black:'#1B1C1D',
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MetricLineGraph',
|
||||
inject: ['getFieldColor'],
|
||||
components: { Line },
|
||||
props: {
|
||||
graph: { type: Object, required: true }, // { fieldIds: [...] }
|
||||
userFields: { type: Object, default: () => ({}) },
|
||||
cycleData: { type: Object, default: () => ({}) },
|
||||
tempChartDays: { type: [Number, String], default: 30 },
|
||||
},
|
||||
computed: {
|
||||
isDark(){
|
||||
return Number(this.$store.getters.getIsNightMode) === 1
|
||||
},
|
||||
surface(){
|
||||
return this.isDark ? '#000000' : '#ffffff'
|
||||
},
|
||||
textColor(){
|
||||
// Text wears text tokens, never the series colour.
|
||||
return this.isDark ? '#d9d9d9' : '#3d3d3d'
|
||||
},
|
||||
gridColor(){
|
||||
// One step off the surface, hairline and recessive.
|
||||
return this.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'
|
||||
},
|
||||
fieldIds(){
|
||||
return this.graph?.fieldIds || []
|
||||
},
|
||||
// Every date present in the data, oldest first, trimmed to the
|
||||
// requested window. Sorting by real date rather than object key
|
||||
// order means out-of-order writes cannot scramble the line.
|
||||
dateCodes(){
|
||||
const codes = Object.keys(this.cycleData)
|
||||
.map(code => ({ code, time: this.codeToDate(code) }))
|
||||
.filter(entry => !isNaN(entry.time))
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.map(entry => entry.code)
|
||||
|
||||
const span = parseInt(this.tempChartDays)
|
||||
if(!span || span < 1 || span >= codes.length){ return codes }
|
||||
return codes.slice(-span)
|
||||
},
|
||||
hasData(){
|
||||
return this.fieldIds.length > 0 && this.dateCodes.length > 0
|
||||
},
|
||||
chartData(){
|
||||
return {
|
||||
labels: this.dateCodes.map(code => this.formatLabel(code)),
|
||||
datasets: this.fieldIds.map(fieldId => {
|
||||
const colour = this.colourFor(fieldId)
|
||||
return {
|
||||
label: this.userFields[fieldId]?.label || fieldId,
|
||||
data: this.dateCodes.map(code => this.valueAt(code, fieldId)),
|
||||
borderColor: colour,
|
||||
backgroundColor: colour,
|
||||
borderWidth: 2,
|
||||
tension: 0.25,
|
||||
// A marker that is drawn is >= 8px with a 2px ring in
|
||||
// the surface colour; dense series only show them on
|
||||
// hover so the line stays readable.
|
||||
pointRadius: this.denseData ? 0 : 4,
|
||||
pointHoverRadius: 5,
|
||||
pointBorderColor: this.surface,
|
||||
pointBorderWidth: 2,
|
||||
pointHoverBorderColor: this.surface,
|
||||
pointHoverBorderWidth: 2,
|
||||
pointHitRadius: 12,
|
||||
spanGaps: false, // a missing day is a gap, not a zero
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
denseData(){
|
||||
return this.dateCodes.length > 40
|
||||
},
|
||||
chartOptions(){
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 200 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: this.textColor, maxRotation: 0, autoSkipPadding: 16 },
|
||||
border: { color: this.gridColor },
|
||||
},
|
||||
y: {
|
||||
grid: { color: this.gridColor, lineWidth: 1, drawTicks: false },
|
||||
ticks: { color: this.textColor, padding: 8 },
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
// Always present: identity is never carried by colour alone,
|
||||
// and it is the relief for the low-contrast light slots.
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: this.textColor,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 14,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: this.isDark ? '#1b1b1b' : '#ffffff',
|
||||
titleColor: this.textColor,
|
||||
bodyColor: this.textColor,
|
||||
borderColor: this.gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const v = ctx.parsed.y
|
||||
return `${ctx.dataset.label}: ${v === null ? '—' : v}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored as D.M.YYYY
|
||||
codeToDate(code){
|
||||
const [d, m, y] = String(code).split('.').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1).getTime()
|
||||
},
|
||||
formatLabel(code){
|
||||
const date = new Date(this.codeToDate(code))
|
||||
if(isNaN(date.getTime())){ return code }
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
},
|
||||
valueAt(code, fieldId){
|
||||
const raw = this.cycleData[code]?.[fieldId]
|
||||
if(raw === undefined || raw === null || raw === ''){ return null }
|
||||
const num = Number(raw)
|
||||
// Non-numeric field types cannot be plotted on a value axis; a gap
|
||||
// is honest, where the old code substituted -1 and bent the scale.
|
||||
return isNaN(num) ? null : num
|
||||
},
|
||||
colourFor(fieldId){
|
||||
const named = this.getFieldColor ? this.getFieldColor(fieldId) : null
|
||||
if(named && FOMANTIC[named]){ return FOMANTIC[named] }
|
||||
|
||||
// Stable slot: keyed off the field's position in userFields, so the
|
||||
// colour follows the field rather than its rank in this graph.
|
||||
const all = Object.keys(this.userFields)
|
||||
const index = all.indexOf(fieldId)
|
||||
const slots = this.isDark ? SERIES.dark : SERIES.light
|
||||
return slots[(index < 0 ? 0 : index) % slots.length]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-graph-holder {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.no-data {
|
||||
padding: 2em 0;
|
||||
text-align: center;
|
||||
color: var(--menu-text, #767676);
|
||||
}
|
||||
</style>
|
||||
@@ -43,12 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -64,7 +64,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
|
||||
<div class="ui basic center aligned segment" v-if="!graphs || graphs.length == 0">
|
||||
<h4 class="ui header">No graphs yet</h4>
|
||||
<p>Choose <b>Add/Edit Graphs</b>, then <b>New Graph</b>, and pick which metrics to plot.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment`">
|
||||
|
||||
<!-- Edit options -->
|
||||
<div class="ui small header" v-if="editGraphs">
|
||||
@@ -126,15 +131,20 @@
|
||||
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
|
||||
Select Graph type and Metrics to display
|
||||
</div>
|
||||
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
|
||||
<div v-if="isLineGraph(graph) && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<LineGraph
|
||||
:graph="graph"
|
||||
:userFields="userFields"
|
||||
:cycleData="cycleData"
|
||||
:tempChartDays="tempChartDays"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui segment" v-if="editGraphs">
|
||||
|
||||
<!-- change graph type -->
|
||||
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':isGraphTypeActive(graphId, graph)}">
|
||||
{{ graphType }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +156,7 @@
|
||||
<span v-else v-on:click="toggleGraphField(fieldId, index)">
|
||||
<i class="square outline icon"></i>
|
||||
</span>
|
||||
<i :class="`${$parent.getFieldColor(fieldId)} ${$parent.getFieldIcon(fieldId)} icon`"></i>
|
||||
<i :class="`${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i>
|
||||
<b>{{ userFields[fieldId]?.label }}</b>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,12 +170,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -189,11 +199,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const PILL_CALENDAR = 'pillCalendar'
|
||||
const LAST_DONE = 'lastDone'
|
||||
|
||||
export default {
|
||||
inject: ['getFieldColor', 'getFieldIcon'],
|
||||
name: 'MetricTrackingGraphs',
|
||||
props: [
|
||||
'tempChartDays', // Number of days to display
|
||||
@@ -205,7 +217,8 @@
|
||||
'editGraphs' // boolean for edit or not edit graphs
|
||||
],
|
||||
components: {
|
||||
'PillCalendarGraph':require('@/components/Metrictracking/PillCalendarGraph.vue').default,
|
||||
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
|
||||
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -221,33 +234,9 @@
|
||||
// Constants
|
||||
this.PILL_CALENDAR = PILL_CALENDAR
|
||||
this.LAST_DONE = LAST_DONE
|
||||
|
||||
// Include JS libraries
|
||||
let graphsScript = document.createElement('script')
|
||||
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
|
||||
document.head.appendChild(graphsScript)
|
||||
},
|
||||
mounted(){
|
||||
this.localGraphData = this.graphs
|
||||
|
||||
this.graphCurrentData()
|
||||
},
|
||||
updated(){
|
||||
// update graphs here? Or watch graphs prop
|
||||
},
|
||||
watch: {
|
||||
// whenever question changes, this function will run
|
||||
userFields(newFields, oldFields) {
|
||||
// console.log([newFields, oldFields])
|
||||
if( JSON.stringify(oldFields) == "{}" ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
tempChartDays(newDays, oldDays){
|
||||
if( newDays != oldDays ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveGraphs(){
|
||||
@@ -267,10 +256,35 @@
|
||||
this.$emit('toggleEditGraphs')
|
||||
},
|
||||
changeGraphType(index, newType){
|
||||
console.log(index + ' change to ' + newType)
|
||||
this.localGraphData[index]['type'] = newType
|
||||
|
||||
// graphTypesDef is an object, so its keys are strings and the
|
||||
// default line graph arrives here as the STRING 'undefined'.
|
||||
// Saved graphs represent the line graph as an absent type, so
|
||||
// remove the key rather than storing a string that no render
|
||||
// test can match.
|
||||
if(this.isLineGraphType(newType)){
|
||||
delete this.localGraphData[index].type
|
||||
}
|
||||
else {
|
||||
this.localGraphData[index].type = newType
|
||||
}
|
||||
|
||||
this.saveGraphs()
|
||||
},
|
||||
// A line graph is the default: an absent type. Accept the string
|
||||
// form too so graphs saved by the older code still render.
|
||||
isLineGraphType(type){
|
||||
return type === undefined || type === null || type === '' || type === 'undefined'
|
||||
},
|
||||
isLineGraph(graph){
|
||||
return this.isLineGraphType(graph?.type)
|
||||
},
|
||||
isGraphTypeActive(graphId, graph){
|
||||
if(this.isLineGraphType(graphId)){
|
||||
return this.isLineGraph(graph)
|
||||
}
|
||||
return String(graphId) === String(graph?.type)
|
||||
},
|
||||
addGraph(){
|
||||
this.localGraphData.push({})
|
||||
this.saveGraphs()
|
||||
@@ -329,103 +343,6 @@
|
||||
|
||||
return title
|
||||
},
|
||||
graphCurrentData(){
|
||||
|
||||
// try again if dygraphs isn't loaded
|
||||
if( typeof(window.Dygraph) != 'function' ){
|
||||
setTimeout(() => {
|
||||
this.graphCurrentData()
|
||||
}, 100)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const graphOptions = {
|
||||
interactionModel: {},
|
||||
// pointClickCallback: function(e, pt){
|
||||
// console.log(e)
|
||||
// console.log(pt)
|
||||
// console.log(this.getValue(pt.idx, 0))
|
||||
// }
|
||||
}
|
||||
|
||||
// Excel date format YYYYMMDD
|
||||
const convertToExcelDate = (dateCode) => {
|
||||
return dateCode
|
||||
.split('.')
|
||||
.reverse()
|
||||
.map(item => String(item).padStart(2,0))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Generate set of keys for graph length
|
||||
let dataKeys = Object.keys(this.cycleData)
|
||||
dataKeys = dataKeys.splice(0, this.tempChartDays)
|
||||
console.log(dataKeys)
|
||||
|
||||
|
||||
// build CSV data for each graph
|
||||
this.graphs.forEach((graph,index) => {
|
||||
|
||||
// only chart line graphs with dygraphs
|
||||
if( graph.type != undefined ){
|
||||
return
|
||||
}
|
||||
if( !graph.fieldIds ){
|
||||
return
|
||||
}
|
||||
|
||||
// CSV or path to a CSV file.
|
||||
let dataString = ""
|
||||
|
||||
// Lookup graph field titles
|
||||
let graphLabels = ['Date']
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
const graphLabel = this.userFields[fieldId]?.label
|
||||
const escapedLabel = graphLabel.replaceAll(',','')
|
||||
graphLabels.push(escapedLabel)
|
||||
})
|
||||
dataString += graphLabels.join(',') + '\n'
|
||||
|
||||
|
||||
// build each row, for each day
|
||||
for (var i = 0; i < dataKeys.length; i++) {
|
||||
|
||||
let nextFragment = []
|
||||
// push date code to first column
|
||||
nextFragment.push(convertToExcelDate(dataKeys[i]))
|
||||
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
|
||||
const currentEntry = this.cycleData[dataKeys[i]]
|
||||
let currentValue = currentEntry[fieldId]
|
||||
|
||||
// setup correct float graphing
|
||||
if(fieldId == 'BT'){
|
||||
// parse temp to fixed length float 00.00
|
||||
currentValue = parseFloat(currentValue).toFixed(2)
|
||||
}
|
||||
|
||||
if( currentValue == undefined ){
|
||||
currentValue = -1
|
||||
}
|
||||
|
||||
nextFragment.push(currentValue)
|
||||
|
||||
})
|
||||
|
||||
dataString += nextFragment.join(',') + "\n"
|
||||
}
|
||||
|
||||
|
||||
let graphDiv = document.getElementById("graphdiv"+index)
|
||||
const g = new Dygraph(graphDiv, dataString ,graphOptions)
|
||||
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +81,13 @@
|
||||
|
||||
}
|
||||
|
||||
.days-since {
|
||||
padding: 0.75em 0.5em 0.25em 0.5em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.days-since-row {
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
.pill-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -229,36 +236,47 @@
|
||||
|
||||
|
||||
|
||||
<span class="pill-container" v-for="(entry, dateCode) in getChartData" v-if="dateCode == `${day}.${calendar.month}.${calendar.year}`">
|
||||
<template v-for="(entry, dateCode) in getChartData" :key="dateCode">
|
||||
<span class="pill-container" v-if="dateCode == `${day}.${calendar.month}.${calendar.year}`">
|
||||
<template v-for="(dayData, fieldId) in entry" :key="fieldId">
|
||||
<span
|
||||
v-for="(dayData, fieldId) in entry"
|
||||
v-if="showZeroValuesCheck(dayData.value, fieldId)"
|
||||
class="pill"
|
||||
:class="[$parent.$parent.getFieldColor(fieldId), {
|
||||
:class="[getFieldColor(fieldId), {
|
||||
'did-next':dayData.didNext,
|
||||
'did-last':dayData.didLast,
|
||||
'last-high':dayData.lastHigh,
|
||||
'next-high':dayData.nextHigh,
|
||||
}]">
|
||||
<!-- 'zero-day':dayData.value == lowestGraphValue, -->
|
||||
<!-- <i v-if="dayData.value != 0" :class="`tiny ${$parent.$parent.getFieldColor(fieldId)} ${$parent.$parent.getFieldIcon(fieldId)} icon`"></i> -->
|
||||
<!-- <i v-if="dayData.value != 0" :class="`tiny ${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i> -->
|
||||
<!-- <span v-else class="icon-spacer"></span>
|
||||
:style="{height:(Math.round(dayData.value*5)+'px')}"
|
||||
|
||||
-->
|
||||
<span v-if="dayData.value > lowestGraphValue-1" class="big-day">
|
||||
<i v-if="!hideIcons" :class="`tiny white ${$parent.$parent.getFieldIcon(fieldId)} icon`"></i>
|
||||
<i v-if="!hideIcons" :class="`tiny white ${getFieldIcon(fieldId)} icon`"></i>
|
||||
<span v-if="!hideValues">
|
||||
{{ getDayValue(fieldId, dayData.value) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<!-- <span v-for="fieldId in graph.fieldIds"></span> -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- time since each metric was last recorded -->
|
||||
<div class="days-since" v-if="daysSince.length > 0">
|
||||
<div class="days-since-row" v-for="entry in daysSince" :key="entry.fieldId">
|
||||
<i :class="`${entry.color} ${entry.icon} icon`"></i>
|
||||
<span v-if="entry.days === null">Days Since {{ entry.label }} - no entries yet</span>
|
||||
<span v-else>Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -269,6 +287,7 @@
|
||||
// let chartData = {}
|
||||
|
||||
export default {
|
||||
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
|
||||
props: [
|
||||
'graph', // options associated with this graph
|
||||
'userFields', // all field attributes
|
||||
@@ -309,6 +328,53 @@
|
||||
this.setupCalendar(new Date())
|
||||
},
|
||||
computed: {
|
||||
// "Days Since <metric> - <n> days" for every field on this graph.
|
||||
// Counts a day as recorded only if it passes the same
|
||||
// showZeroValuesCheck the pills use, so the "Show/Hide Lowest Value"
|
||||
// toggle moves these numbers too.
|
||||
daysSince(){
|
||||
|
||||
// showZeroValuesCheck reads lowestGraphValue, which getChartData
|
||||
// assigns as a side effect -- touch it so the threshold is current
|
||||
// and so this recomputes whenever the chart data does.
|
||||
void this.getChartData
|
||||
|
||||
const dayMs = 86400000
|
||||
const midnight = (time) => {
|
||||
const date = new Date(time)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
const today = midnight(Date.now())
|
||||
|
||||
return (this.graph?.fieldIds || []).map(fieldId => {
|
||||
|
||||
let latest = null
|
||||
|
||||
Object.keys(this.cycleData || {}).forEach(dateCode => {
|
||||
|
||||
const value = this.cycleData[dateCode]?.[fieldId]
|
||||
if(value === undefined || value === null || value === ''){ return }
|
||||
|
||||
// honour the Lowest Value toggle
|
||||
if(!this.showZeroValuesCheck(value, fieldId)){ return }
|
||||
|
||||
const time = this.dateCodeToTime(dateCode)
|
||||
// ignore unparseable codes and anything dated ahead of today
|
||||
if(time === null || time > today){ return }
|
||||
|
||||
if(latest === null || time > latest){ latest = time }
|
||||
})
|
||||
|
||||
return {
|
||||
fieldId,
|
||||
label: this.getFieldLabel(fieldId),
|
||||
icon: this.getFieldIcon(fieldId),
|
||||
color: this.getFieldColor(fieldId),
|
||||
days: latest === null ? null : Math.round((today - latest) / dayMs),
|
||||
}
|
||||
})
|
||||
},
|
||||
getChartData(){
|
||||
|
||||
let chartData = {}
|
||||
@@ -389,11 +455,26 @@
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored unpadded as D.M.YYYY
|
||||
dateCodeToTime(dateCode){
|
||||
|
||||
const parts = String(dateCode).split('.').map(Number)
|
||||
if(parts.length !== 3 || parts.some(isNaN)){ return null }
|
||||
|
||||
const [day, month, year] = parts
|
||||
const date = new Date(year, month - 1, day)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
return isNaN(date.getTime()) ? null : date.getTime()
|
||||
},
|
||||
showZeroValuesCheck(dayValue, fieldId){
|
||||
|
||||
// Optional chaining: a graph can reference a default field (BT,
|
||||
// CM, ...) that was never customised, so it has no userFields
|
||||
// entry and the bare lookup threw.
|
||||
// if graph type is boolean or there are two options
|
||||
let isBooleanField = this.userFields[fieldId].type == 'boolean'
|
||||
if(this.userFields[fieldId].customOptions){
|
||||
let isBooleanField = this.userFields[fieldId]?.type == 'boolean'
|
||||
if(this.userFields[fieldId]?.customOptions){
|
||||
let options = this.userFields[fieldId].customOptions
|
||||
|
||||
isBooleanField = options.split(',').length == 2
|
||||
@@ -411,6 +492,13 @@
|
||||
}
|
||||
|
||||
|
||||
// lowestGraphValue comes from Math.min of the charted values, so
|
||||
// it is Infinity when the window holds no data -- without the
|
||||
// finite guard nothing would ever clear the threshold.
|
||||
if(!isFinite(this.lowestGraphValue)){
|
||||
return true
|
||||
}
|
||||
|
||||
return this.showZeroValues || dayValue > this.lowestGraphValue
|
||||
},
|
||||
getDayValue(fieldId, value){
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/table`)" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('table')" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<i class="border all icon"></i>
|
||||
</div> -->
|
||||
|
||||
@@ -87,13 +87,13 @@
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/colors`)" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('colors')" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<i class="paint brush icon"></i>
|
||||
</div> -->
|
||||
<!-- <div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)" data-tooltip="Tags" data-position="top center">
|
||||
<!-- <div class="edit-button" v-on:click="openMenuRoute('tags')" data-tooltip="Tags" data-position="top center">
|
||||
<i class="tags icon"></i>
|
||||
</div> -->
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/images`)" data-tooltip="Images" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('images')" data-tooltip="Images" data-position="top center">
|
||||
<i class="image icon"></i>
|
||||
<span>Images</span>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/options`)" data-tooltip="More Options" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('options')" data-tooltip="More Options" data-position="top center">
|
||||
|
||||
<i class="ellipsis horizontal icon"></i>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
|
||||
<!-- tags on the side, only show on desktop -->
|
||||
<div class="note-mini-tag-area"
|
||||
v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)"
|
||||
v-on:click="openMenuRoute('tags')"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
|
||||
|
||||
<span class="add-mini-tag" v-if="allTags.length == 0">
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
<!-- Squire Box -->
|
||||
<div
|
||||
id="squire-id"
|
||||
:id="`squire-id-${noteid}`"
|
||||
class="squire-box"
|
||||
ref="squirebox"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
|
||||
@@ -359,33 +359,32 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const Squire = require('../assets/squire.js')
|
||||
|
||||
import '../assets/squire.js' // side-effect: sets window.Squire
|
||||
const Squire = window.Squire
|
||||
import axios from 'axios'
|
||||
// const crypto = require('crypto')
|
||||
const DiffMatchPatch = require('../../../server/helpers/DiffMatchPatch')
|
||||
const dmp = new DiffMatchPatch.diff_match_patch()
|
||||
import DiffMatchPatch from 'diff-match-patch'
|
||||
const dmp = new DiffMatchPatch()
|
||||
|
||||
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
|
||||
|
||||
let rawNoteText = '' // Used for comparing and generating diffs
|
||||
|
||||
export default {
|
||||
name: 'NoteInputPanel',
|
||||
props: [ 'noteid', 'position', 'openMenu', 'urlData', 'openNotes'],
|
||||
components:{
|
||||
'note-tag-edit': () => import('@/components/NoteTagEdit.vue'),
|
||||
'color-picker': () => import('@/components/ColorPicker.vue'),
|
||||
'file-upload-button': () => import('@/components/FileUploadButton.vue'),
|
||||
// 'delete-button': () => import('@/components/NoteDeleteButtonComponent.vue'),
|
||||
'side-slide-menu': () => import('@/components/SideSlideMenuComponent.vue'),
|
||||
'simple-attachment-note': () => import('@/components/SimpleAttachmentNoteComponent.vue'),
|
||||
'share-note-component': () => import('@/components/ShareNoteComponent.vue'),
|
||||
'note-tag-edit': defineAsyncComponent(() => import('@/components/NoteTagEdit.vue')),
|
||||
'color-picker': defineAsyncComponent(() => import('@/components/ColorPicker.vue')),
|
||||
'file-upload-button': defineAsyncComponent(() => import('@/components/FileUploadButton.vue')),
|
||||
// 'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
|
||||
'side-slide-menu': defineAsyncComponent(() => import('@/components/SideSlideMenuComponent.vue')),
|
||||
'simple-attachment-note': defineAsyncComponent(() => import('@/components/SimpleAttachmentNoteComponent.vue')),
|
||||
'share-note-component': defineAsyncComponent(() => import('@/components/ShareNoteComponent.vue')),
|
||||
|
||||
'color-tooltip':require('@/components/TextColorTooltipComponent.vue').default,
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default,
|
||||
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
|
||||
'color-tooltip':defineAsyncComponent(() => import('@/components/TextColorTooltipComponent.vue')),
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue')),
|
||||
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
},
|
||||
mixins:[ SquireButtonFunctions ],
|
||||
data(){
|
||||
@@ -419,6 +418,9 @@
|
||||
|
||||
//All the squire settings
|
||||
editor: null,
|
||||
// Baseline for diff generation. Per-instance: as a module-scoped
|
||||
// variable this was shared by every open note's panel.
|
||||
rawNoteText: '',
|
||||
usersOnNote: 0,
|
||||
|
||||
sideMenuOpen: false,
|
||||
@@ -475,25 +477,17 @@
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$bus.$on('new_file_upload', ({noteId, imageCode}) => {
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
})
|
||||
|
||||
this.$bus.$on('close_note_by_id', (noteId) => {
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
})
|
||||
// Named handlers so beforeUnmount can remove only THIS panel's.
|
||||
// mitt's off(type) with no handler removes every panel's.
|
||||
this.$bus.$on('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$on('close_note_by_id', this.onCloseNoteById)
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
this.$io.emit('leave_room', this.rawTextId)
|
||||
this.$io.emit('leave_room', { noteId: this.noteid })
|
||||
|
||||
this.$bus.$off('new_file_upload')
|
||||
this.$bus.$off('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$off('close_note_by_id', this.onCloseNoteById)
|
||||
|
||||
this.destroyAttachmentStyles()
|
||||
|
||||
@@ -533,13 +527,13 @@
|
||||
},
|
||||
methods: {
|
||||
testModify(){
|
||||
const text = document.getElementById('squire-id').children[0].innerHTML
|
||||
const text = this.$refs.squirebox.children[0].innerHTML
|
||||
const prepended = 'Hello-> ' + text
|
||||
document.getElementById('squire-id').children[0].innerHTML = prepended
|
||||
this.$refs.squirebox.children[0].innerHTML = prepended
|
||||
|
||||
const text1 = document.getElementById('squire-id').children[2].innerHTML
|
||||
const text1 = this.$refs.squirebox.children[2].innerHTML
|
||||
const prepended1 = 'Hey-> ' + text1
|
||||
document.getElementById('squire-id').children[2].innerHTML = prepended1
|
||||
this.$refs.squirebox.children[2].innerHTML = prepended1
|
||||
},
|
||||
simulateTyping(index = 0){
|
||||
|
||||
@@ -593,7 +587,7 @@
|
||||
this.setText(this.noteText)
|
||||
|
||||
// Use squire box HTML for diff/patch changes
|
||||
rawNoteText = document.getElementById('squire-id').innerHTML
|
||||
this.rawNoteText = this.$refs.squirebox.innerHTML
|
||||
|
||||
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
|
||||
if(!this.$store.getters.getIsUserOnMobile){
|
||||
@@ -867,6 +861,8 @@
|
||||
},
|
||||
//Called on squire event for keyup
|
||||
diffText(event){
|
||||
console.log('Skipping diff text function')
|
||||
return
|
||||
// console.log(event.type)
|
||||
|
||||
const diffEvents = ['keyup','pathChange', 'click']
|
||||
@@ -880,17 +876,17 @@
|
||||
this.diffTextTimeout = setTimeout(() => {
|
||||
|
||||
// Current Editor Text
|
||||
const liveEditorElm = document.getElementById('squire-id')
|
||||
const liveEditorElm = this.$refs.squirebox
|
||||
|
||||
// virtual element for selecting div
|
||||
let virtualEditorElm = document.createElement('div')
|
||||
virtualEditorElm.innerHTML = rawNoteText
|
||||
virtualEditorElm.innerHTML = this.rawNoteText
|
||||
|
||||
// element at cursor
|
||||
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
// Remove beginngin selector from path, make it more generic
|
||||
const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','')
|
||||
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
|
||||
let workingPath = ''
|
||||
|
||||
// default to entire note text, select down if path
|
||||
@@ -938,10 +934,10 @@
|
||||
const patch_text = dmp.patch_toText(patch_list)
|
||||
|
||||
// save raw text for future diffs
|
||||
rawNoteText = liveEditorElm.innerHTML
|
||||
this.rawNoteText = liveEditorElm.innerHTML
|
||||
|
||||
let newPatch = {
|
||||
id: this.rawTextId,
|
||||
noteId: this.noteid,
|
||||
diff: patch_text,
|
||||
path: path,
|
||||
// testing metrics
|
||||
@@ -961,7 +957,10 @@
|
||||
// console.log('incoming patches ', incomingPatchs)
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
|
||||
// Socket-driven: this panel may not be rendered yet, or may be gone
|
||||
if(!editorElement){ return resolve(false) }
|
||||
|
||||
// iterate over incoming patches because they apply to specific divs
|
||||
incomingPatchs.forEach(patch => {
|
||||
@@ -988,10 +987,10 @@
|
||||
})
|
||||
|
||||
// save editor HTML after change for future comparisons
|
||||
rawNoteText = editorElement.innerHTML
|
||||
this.rawNoteText = editorElement.innerHTML
|
||||
|
||||
// update hash on patch
|
||||
this.lastNoteHash = this.hashString( rawNoteText )
|
||||
this.lastNoteHash = this.hashString( this.rawNoteText )
|
||||
|
||||
this.$nextTick(() => {
|
||||
return resolve(true)
|
||||
@@ -1041,7 +1040,7 @@
|
||||
|
||||
//tell websockets to truncate history at this save
|
||||
this.lastNoteHash = currentHash //Update last saved note hash
|
||||
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
|
||||
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
|
||||
|
||||
const postData = {
|
||||
'noteId': this.currentNoteId,
|
||||
@@ -1068,6 +1067,10 @@
|
||||
},
|
||||
loadNoteNextFromServer(){
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.reloadNoteDebounce)
|
||||
this.reloadNoteDebounce = setTimeout(() => {
|
||||
|
||||
@@ -1075,13 +1078,28 @@
|
||||
// this.setText('')
|
||||
|
||||
//Focus Regained on Note, check for update
|
||||
console.log('Ajax Call to /api/note/get')
|
||||
axios.post('/api/note/get', { 'noteId': this.noteid })
|
||||
.then(response => {
|
||||
|
||||
this.setupLoadedNoteData(response)
|
||||
// Skip note update if it didn't change.
|
||||
const newNoteHash = this.hashString( response.data.text )
|
||||
if(newNoteHash == this.lastNoteHash){
|
||||
console.log('Skipping any changes. Notes match')
|
||||
return
|
||||
}
|
||||
|
||||
//Manually set squire text to show
|
||||
this.setText(this.noteText)
|
||||
this.setupLoadedNoteData(response)
|
||||
|
||||
//Manually set squire text
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return }
|
||||
editorElement.innerHTML = response.data.text
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
@@ -1098,6 +1116,7 @@
|
||||
//If user leaves page then returns to page, reload the first batch
|
||||
if(checkForUpdateTimeout){
|
||||
|
||||
console.log('Focused then unfocused for greater than 3 seconds. Checking with server.')
|
||||
this.loadNoteNextFromServer()
|
||||
this.lastInteractionTimestamp = now
|
||||
}
|
||||
@@ -1127,6 +1146,15 @@
|
||||
|
||||
return hash;
|
||||
},
|
||||
// Menu routes are pushed (not replaced) because the history entry is
|
||||
// load-bearing: every menu closes via $router.go(-1). Forwarding the
|
||||
// current query keeps ?open= alive across the menu and the pop back.
|
||||
openMenuRoute(menu){
|
||||
this.$router.push({
|
||||
path: `/notes/open/${this.noteid}/menu/${menu}`,
|
||||
query: this.$route.query,
|
||||
})
|
||||
},
|
||||
closeButtonAction(playAnimation = false){
|
||||
this.sizeDown = playAnimation
|
||||
const animationTimeout = (playAnimation ? 300 : 0)
|
||||
@@ -1162,60 +1190,106 @@
|
||||
})
|
||||
},
|
||||
destroyWebSockets(){
|
||||
this.$io.removeListener('past_diffs')
|
||||
this.$io.removeListener('update_user_count')
|
||||
this.$io.removeListener('incoming_diff')
|
||||
this.$io.removeListener('update_note_attachments')
|
||||
|
||||
// By reference: removeListener(event) alone would remove the other
|
||||
// open panels' handlers too. new_note_text_saved was never removed
|
||||
// at all and leaked a handler on every open/close.
|
||||
this.$io.removeListener('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.removeListener('past_diffs', this.onPastDiffs)
|
||||
this.$io.removeListener('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.removeListener('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.removeListener('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
|
||||
clearTimeout(this.idleNoteTimeout)
|
||||
},
|
||||
// Socket handlers are named methods rather than inline closures so that
|
||||
// destroyWebSockets() can remove THIS panel's handler by reference.
|
||||
// removeListener(event) with no handler drops every open panel's.
|
||||
onFileUpload({noteId, imageCode}){
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onCloseNoteById(noteId){
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
},
|
||||
onUpdateUserCount(userCount){
|
||||
this.usersOnNote = userCount
|
||||
},
|
||||
//Apply all diffs since last save
|
||||
onPastDiffs(diffSinceLastUpdate){
|
||||
|
||||
// Every open panel shares one socket, so filter to this note's room
|
||||
if(!this.isDiffForThisNote(diffSinceLastUpdate)){ return }
|
||||
|
||||
if(diffSinceLastUpdate != null){
|
||||
this.diffsApplied = diffSinceLastUpdate.length
|
||||
}
|
||||
|
||||
this.patchText(diffSinceLastUpdate)
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
},
|
||||
onIncomingDiff(incomingDiff){
|
||||
|
||||
if(!this.isDiffForThisNote([incomingDiff])){ return }
|
||||
|
||||
this.patchText([incomingDiff])
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
},
|
||||
// A diff payload carries the raw-text id it belongs to. Anything without
|
||||
// one is dropped rather than guessed at -- applying a patch to the wrong
|
||||
// note is how note bodies got clobbered.
|
||||
isDiffForThisNote(diffs){
|
||||
|
||||
if(diffs == null){ return false }
|
||||
|
||||
const list = [].concat(diffs).filter(diff => diff != null)
|
||||
if(list.length == 0){ return false }
|
||||
|
||||
return list.every(diff => String(diff.rawTextId) === String(this.rawTextId))
|
||||
},
|
||||
onNewNoteTextSaved({noteId, hash}){
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
const sameIdCheck = (this.idleNote && this.noteid == noteId)
|
||||
const differentHashCheck = (hash != this.lastNoteHash)
|
||||
|
||||
// if hashes do not match, reload text from server
|
||||
if(sameIdCheck && differentHashCheck){
|
||||
this.loadNoteNextFromServer()
|
||||
}
|
||||
},
|
||||
onUpdateNoteAttachments(){
|
||||
this.getAttachments()
|
||||
},
|
||||
initWebsocketEvents(){
|
||||
|
||||
//Tell server to push this note into a room
|
||||
this.$io.emit('join_room', this.rawTextId )
|
||||
|
||||
this.$io.on('update_user_count', userCount => {
|
||||
this.usersOnNote = userCount
|
||||
})
|
||||
|
||||
//Apply all diffs since last save
|
||||
this.$io.on('past_diffs', diffSinceLastUpdate => {
|
||||
|
||||
if(diffSinceLastUpdate != null){
|
||||
this.diffsApplied = diffSinceLastUpdate.length
|
||||
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
|
||||
}
|
||||
// console.log(diffSinceLastUpdate)
|
||||
this.patchText(diffSinceLastUpdate)
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
})
|
||||
})
|
||||
|
||||
this.$io.on('incoming_diff', incomingDiff => {
|
||||
|
||||
this.patchText([incomingDiff])
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
})
|
||||
})
|
||||
|
||||
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
|
||||
|
||||
const sameIdCheck = (this.idleNote && this.noteid == noteId)
|
||||
const differentHashCheck = (hash != this.lastNoteHash)
|
||||
|
||||
// if hashes do not match, reload text from server
|
||||
if(sameIdCheck && differentHashCheck){
|
||||
this.loadNoteNextFromServer()
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
this.$io.on('update_note_attachments', () => {
|
||||
this.getAttachments()
|
||||
// Ask for a note, not a room. The server authorises against the
|
||||
// token and derives the room itself.
|
||||
this.$io.emit('join_room', {
|
||||
noteId: this.noteid,
|
||||
token: localStorage.getItem('loginToken'),
|
||||
})
|
||||
|
||||
this.$io.on('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.on('past_diffs', this.onPastDiffs)
|
||||
this.$io.on('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.on('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.on('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
},
|
||||
logNoteInteraction(){
|
||||
|
||||
@@ -1228,6 +1302,8 @@
|
||||
},
|
||||
saveCaretPosition(event){
|
||||
|
||||
return
|
||||
|
||||
//Find index of child element based on past range
|
||||
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
@@ -1235,6 +1311,8 @@
|
||||
this.lastRange = this.generateSelector(element)
|
||||
this.startOffset = parseInt(event.range.startOffset) || 0
|
||||
|
||||
console.log('Save Range ', [element, this.lastRange, this.startOffset])
|
||||
|
||||
return
|
||||
},
|
||||
restoreCaretPosition(){
|
||||
@@ -1244,7 +1322,9 @@
|
||||
|
||||
if(!this.lastRange){ return resolve(true) }
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return resolve(true) }
|
||||
|
||||
const lastElement = editorElement.querySelector(this.lastRange)
|
||||
|
||||
if( !lastElement ){ return resolve(true) }
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -191,9 +192,9 @@
|
||||
name: 'NoteTitleDisplayCard',
|
||||
props: [ 'onClick', 'data', 'currentlyOpen', 'textResults', 'attachmentResults', 'tagResults', 'titleView' ],
|
||||
components: {
|
||||
'delete-button': require('@/components/NoteDeleteButtonComponent.vue').default,
|
||||
'note-tag-edit': require('@/components/NoteTagEdit.vue').default,
|
||||
'side-slide-menu': require('@/components/SideSlideMenuComponent.vue').default,
|
||||
'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
|
||||
'note-tag-edit': defineAsyncComponent(() => import('@/components/NoteTagEdit.vue')),
|
||||
'side-slide-menu': defineAsyncComponent(() => import('@/components/SideSlideMenuComponent.vue')),
|
||||
},
|
||||
methods:{
|
||||
cardClicked(){
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</span>
|
||||
</template>
|
||||
@@ -104,7 +103,7 @@
|
||||
this.tagSuggestions = []
|
||||
})
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
this.$bus.$off('reset_fast_filters')
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
@@ -107,11 +107,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
export default {
|
||||
name: 'SideSlideMenu',
|
||||
props: [ 'name', 'styleObject', 'fullShadow', 'skipHistory' ],
|
||||
components: {
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -129,7 +130,7 @@
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
},
|
||||
mounted(){
|
||||
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<template>
|
||||
<div class="svg-holder" v-if="svg[file]" v-html="svg[file]" :alt="alt"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props:[ 'file','alt' ],
|
||||
data() {
|
||||
return {
|
||||
//Remove title, remove id from each SVG
|
||||
svg: {
|
||||
'idea':'<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="1013.02965" height="844.99456" viewBox="0 0 1013.02965 844.99456"><path d="M1050.48389,297.81272c0,35.17-8.33,64.12-22.7799,88.92a177.67758,177.67758,0,0,1-16.19,23.25c-53.12,64.97-151.88,100.53-241.14,158.13-263.48,170.02-292.02-102.42-280.12-270.3,4.8-67.74,31.18-129.79,73.09-177.3a278.22062,278.22062,0,0,1,28.45-27.97c47.58-40.52,109.05-65.04,178.58-65.04,119.29,0,221.16,71.95,261.55,173.34a261.84957,261.84957,0,0,1,18.55993,96.97Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M830.054,88.4227l-266.71,32.09a278.22062,278.22062,0,0,1,28.45-27.97Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1027.704,386.7327a177.67758,177.67758,0,0,1-16.19,23.25l-228.46-27.48Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1041.2439,228.72269l-387.18994-50.22,377.87,22.34A260.439,260.439,0,0,1,1041.2439,228.72269Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04a27.99706,27.99706,0,1,1-52.68-15.11,25.73956,25.73956,0,0,1,2.06-3.31,27.9889,27.9889,0,0,1,50.6,14.1C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04l-41.92-5.04,41.9.72C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M506.51853,113.61484l-38.78,4.67a28.05694,28.05694,0,0,1-2.96-3.95Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M495.51853,82.61484l-31.76,3.82a25.73956,25.73956,0,0,1,2.06-3.31Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443a27.99706,27.99706,0,1,1-39.45-38.042,25.73938,25.73938,0,0,1,3.37127-1.9581,27.98891,27.98891,0,0,1,38.08621,36.17477C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443L864.5584,581.29206l36.67354,20.27763C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M884.80385,611.4064l-36.44426-14.054a28.05745,28.05745,0,0,1-.76292-4.87668Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M889.61936,578.867l-29.8449-11.514a25.73938,25.73938,0,0,1,3.37127-1.9581Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163a27.99707,27.99707,0,1,1-48.26355,25.96387,25.73851,25.73851,0,0,1-.83628-3.80793,27.9889,27.9889,0,0,1,46.06931-25.23471C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163l-33.52955,25.66038,30.499-28.73915C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1101.25637,138.03864l-24.50216,30.41946a28.0572,28.0572,0,0,1-4.87694-.76118Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1071.73725,123.52584l-20.07,24.90959a25.73851,25.73851,0,0,1-.83628-3.80793Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M573.26553,636.95591s61.44557,2.56024,61.44557,17.92163-69.12627,0-69.12627,0Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M481.09717,872.49728l-115.21045-7.6807L360.76625,803.371l-25.60232,53.76488-112.65022-15.3614c2.56023-97.28882,43.524-192.01741,46.08418-204.81858s8.67923-65.69564,8.67923-65.69564c11.1882-23.73325,69.81727-25.96064,115.77362-23.81013,19.04829.87055,35.94576,2.509,46.36577,3.68674,6.88688.768,10.95787,1.33137,10.95787,1.33137,18.35667,18.92015,30.18524,44.70155,37.45621,73.50417C514.04734,729.73871,481.09717,872.49728,481.09717,872.49728Z" transform="translate(-93.48517 -27.50272)" fill="#2f2e41"/><circle cx="259.60038" cy="187.01487" r="64.00581" fill="#a0616a"/><path d="M309.56161,229.879S291.64,309.24619,263.47743,332.28828s110.09,0,110.09,0-30.72278-51.20465-7.68069-74.24674S309.56161,229.879,309.56161,229.879Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M440.13345,557.58871,397.659,562.29945,278.83882,575.51034s-.56318-1.51061-1.5617-4.25007c-8.628-23.52854-50.18049-138.022-54.76341-172.406-5.12046-38.40349,48.64441-84.48767,48.64441-84.48767s51.20465-10.24093,66.566-10.24093,76.807,23.04209,76.807,23.04209l22.914,206.12433,1.97143,17.84474Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M487.83059,625.97242,401.73,575.51034s-1.51076-4.91576-4.071-13.21089c-1.28011-4.14756-2.8415-9.16551-4.60823-14.84931-9.93371-31.90055-26.60085-84.53892-39.96518-122.9935-20.48186-58.88534,79.36719,104.96952,79.36719,104.96952lLine truncated
|
||||
'onboarding':`<svg id="70bb2b30-be60-4de4-9e4b-86e23973444e" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1143.11" height="875.78" viewBox="0 0 1143.11 875.78"><defs><linearGradient id="e5da6213-051c-4da8-b1b3-926fff123e76" x1="539.33" y1="763.29" x2="539.33" y2="57.83" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="052f7d93-657d-41ac-9ec0-ae0276bba283" x1="1034.54" y1="812.84" x2="1034.54" y2="197.81" gradientTransform="matrix(-1, 0, 0, 1, 1347.17, 0)" xlink:href="#e5da6213-051c-4da8-b1b3-926fff123e76"/></defs><title>onboarding</title><ellipse cx="849.67" cy="829.19" rx="115.8" ry="28.95" fill="var(--main-accent)" opacity="0.1"/><path d="M63.7,616.77c-13.1,4.49-29,13.65-26.32,28.21,1.34,7.38,7.32,12.85,13,17.47a368.3,368.3,0,0,0,79.36,49.15c13.57,6.16,28,11.81,38.25,23.17s15,27.24,18.33,42.55a345.86,345.86,0,0,1,5.72,110.41c40.91.78,86.21-.14,115.16-29.61,17.33-17.65,26.62-43.83,47-57.49,13.59-9.11,30.34-11.37,46.52-13.27q114.87-13.43,230.93-16.26c32.42-.79,65.55-1.07,95.82-12.37s57.75-36,63.9-69.49c.95-5.16,1.57-10.85,5.1-14.45,3.15-3.23,7.87-4,12.28-4.57l265.89-36c14.56-2,29.63-4.13,41.89-12.36,11.86-8,19.86-20.78,27.51-33.24,17.53-28.52,35.72-63,23.58-95.61-9.71-26.06-35.81-41.07-60.75-50.39s-51.79-15.87-72.06-34.14c-30.84-27.8-37.68-73.95-46.37-115.36a675.43,675.43,0,0,0-33-109.89C943,151.89,927.85,121,905.27,96.61S851.62,54.93,820,56.17C778.7,57.81,742.37,90,701,86c-34.94-3.35-63.37-31.35-94.83-48.77C550.32,6.28,484.4,9,423.29,18.65c-64.8,10.21-130,28.08-182.5,68.27-12,9.2-23.48,19.82-30.53,33.66-10.8,21.23-9.87,47.1-15.28,70.69-21.8,95-138.68,134.63-162.71,228.94C23.73,453.69,30.05,490,45.72,520.94c16.85,33.31,50.7,47.51,68.05,76.1C102.17,607.63,79.06,611.5,63.7,616.77Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)" opacity="0.1"/><path d="M898.6,849s-10.48-72.2,21-119.72a110.5,110.5,0,0,0,18.68-67.38,185.42,185.42,0,0,0-5.1-33.61" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M899.46,846.71s-38.84-61.76-29.27-118A110.5,110.5,0,0,0,860,659.56a185.49,185.49,0,0,0-18.29-28.66" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M847.51,595.43c2.32,8.26-5.87,36.41-5.87,36.41s-21.7-19.72-24-28a15.53,15.53,0,1,1,29.9-8.44Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M885.11,634.19c-2.53,8.19-24.71,27.37-24.71,27.37sLine truncated
|
||||
'cloud':`<svg id="b75ffde3-65ff-4761-a8e9-ebaeba35e9db" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="863.67584" height="787.45556" viewBox="0 0 863.67584 787.45556"><title>cloud_docs</title><rect x="674.64346" y="434.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="281.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="87.39412" width="2" height="140" fill="#f2f2f2"/><circle cx="675.64346" cy="434.39412" r="25" fill="#f2f2f2"/><circle cx="675.64346" cy="267.39412" r="47" fill="#f2f2f2"/><circle cx="675.64346" cy="87.39412" r="63" fill="#f2f2f2"/><circle cx="650.64346" cy="59.39412" r="10" fill="#d0cde1"/><circle cx="690.64346" cy="81.39412" r="6" fill="#d0cde1"/><circle cx="675.64346" cy="113.39412" r="6" fill="#d0cde1"/><path d="M558.5736,732.72005a443.16545,443.16545,0,0,1-168.1455-33.12793A356.65064,356.65064,0,0,1,194.67956,234.2889,285.75854,285.75854,0,0,1,567.183,77.58041,228.807,228.807,0,0,1,692.65905,375.84359,183.245,183.245,0,0,1,453.788,476.33431a146.79491,146.79491,0,0,1-80.502-191.35742,117.77062,117.77062,0,0,1,153.34668-64.51123,94.41622,94.41622,0,0,1,51.71875,122.938A75.64668,75.64668,0,0,1,479.7406,384.888a60.71786,60.71786,0,0,1-33.29786-79.14941A48.831,48.831,0,0,1,510.02331,278.991a39.264,39.264,0,0,1,21.50781,51.125,31.612,31.612,0,0,1-41.16064,17.31641,25.4898,25.4898,0,0,1-13.9624-33.19,20.59,20.59,0,0,1,26.812-11.27929,16.67345,16.67345,0,0,1,9.1333,21.71,13.5396,13.5396,0,0,1-17.62891,7.4165,11.03155,11.03155,0,0,1-6.043-14.36377,9.02471,9.02471,0,0,1,11.752-4.94384,7.42021,7.42021,0,0,1,4.06445,9.66211,6.13434,6.13434,0,0,1-7.99023,3.36132,5.10967,5.10967,0,0,1-2.79883-6.65283,4.283,4.283,0,0,1,5.583-2.34863,3.62962,3.62962,0,0,1,1.98877,4.72705,3.10075,3.10075,0,0,1-4.043,1.70019,2.67971,2.67971,0,0,1-1.46924-3.49462,2.34588,2.34588,0,0,1,.52344-.78467,2.2689,2.2689,0,0,0-.73145.95556,3.10864,3.10864,0,0,0,1.70215,4.04639,4.13478,4.13478,0,0,0,5.38379-2.26465,5.41865,5.41865,0,0,0-2.96826-7.05566,7.02632,7.02632,0,0,0-9.145,3.84717,9.0309,9.0309,0,0,0,4.94629,11.75732,11.54031,11.54031,0,0,0,15.02246-6.31982,14.67283,14.67283,0,0,0-8.03662-19.10352,18.58956,18.58956,0,0,0-24.20557,10.18262,23.4616,23.4616,0,0,0,12.86572,30.583,29.61209,29.61209,0,0,0,38.5542-16.21924,37.26339,37.26339,0,0,0-20.41113-48.51856,46.83052,46.83052,0,0,0-60.97412,25.65088A58.718,58.718,0,0,0,480.49548,383.036a73.64788,73.64788,0,0,0,96.00439-40.38721A92.41557,92.41557,0,0,0,525.8778,222.31722a115.77022,115.77022,0,0,0-150.74023,63.41455,144.79736,144.79736,0,0,0,79.40527,188.751,181.24485,181.24485,0,0,0,236.26465-99.394A226.80605,226.80605,0,0,0,566.4281,79.43246C421.5448,20.35384,255.60974,90.161,196.53112,235.04378,122.63122,416.27523,209.952,623.84017,391.183,697.74056c226.666,92.42676,486.269-16.78515,578.69678-243.45215l1.85156.75586A446.43912,446.43912,0,0,1,564.39,732.68294C562.45153,732.70736,560.50915,732.72005,558.5736,732.72005ZM497.93982,320.27718a.3519.3519,0,0,0-.13428.02735.34584.34584,0,0,0-.18555.1875.6803.6803,0,0,0,.37305.8872,1.10164,1.10164,0,0,0,1.436-.603,1.62634,1.62634,0,0,0-.7207-2.03858l-.63916,1.56543A.33875.33875,0,0,0,497.93982,320.27718Z" transform="translate(-168.16208 -56.27222)" fill="#3f3d56"/><circle cx="489.56553" cy="232.87527" r="49.53794" fill="#2f2e41"/><polygon points="544.143 380.894 550.143 399.894 472.143 411.894 473.143 391.894 544.143 380.894" fill="#a0616a"/><path d="M621.30554,585.16634l-3,61s-17,31-15,75,1,45,1,45l-1,16,16,2v-21s23-58,25-87l23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M734.30554,585.16634l3,61s17,31,15,75-1,45-1,45l1,16-16,2v-21s-23-58-25-87l-23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M718.30554,450.16634s-72,11-83,8l-36,125s33,13,73,9a34.86506,34.86506,0,0,1,24,4c12,7,48-12,48-12l25-5Z" transform="translate(-168.16208 -56.27222)" fill="#2f2e41"/><rect x="496.64953" y="407.40309" width="80" height="91" fill="var(--main-accent)"/><rect x="484.63738" y="417.38516" width="80" height="91" fill="#f2f2f2"/><path d="M605.30554,389.16634l-1,14,18,62s7,44,21,70c0,0,8,40,23,35s-7-44-7-44l-16-60-19-79Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><circle cx="493.64346" cy="250.39412" r="30" fill="#a0616a"/><path d="M644.30554,324.16634s9,27-9,32-36,16-32,34l2,4,125-30a27.43931,27.43931,0,0,0-27-13s-27.807-.73647-25.9035-18.86823l-3.0965-7.13177Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M726.30554,362.16634s5,0,18,17,22,26,22,26,12,6,5,20-32,28-32,28-25,30-28,15,21-23,21-23,18-17,17-27l-41-34Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M601.30554,387.16634s124-29,127-29l6.29076,9.74735s-1.29076,7.25265-16.29076,9.25265c0,0,3,15-3,30v37s-70,8-76,14l-10.77042-53.41383s-14.22958-8.58617-24.22958-1.58617C604.30554,403.16634,600.30554,390.16634,601.30554,387.16634Z" transform="translate(-168.16208 -56.27222)" fill="#d0cde1"/><path d="M621.30554,780.16634sLine truncated
|
||||
'secure':'<svg id="4687a0c2-bd42-4093-8bbc-92d10a889ded" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="866" height="720.37" viewBox="0 0 866 720.37"><defs><linearGradient id="01871234-c547-4dad-9a9e-117fcc2bb7d8" x1="660.5" y1="458.72" x2="660.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="705039c9-bbaf-489f-8754-0d88ff1d230d" x1="802.94" y1="120.14" x2="802.94" y2="40.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="f1b18cc7-88bb-4220-8574-cb67e65e6847" x1="802.94" y1="269.14" x2="802.94" y2="189.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="729136a4-2a29-4999-b297-e6b2d53009e3" x1="802.94" y1="417.14" x2="802.94" y2="337.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="7a22862c-2f66-4029-a70d-d91858435973" x1="475.1" y1="785.18" x2="475.1" y2="418.21" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="668716b8-0ce2-4b29-be6e-2467fe221975" x1="475.5" y1="810.19" x2="475.5" y2="750.5" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="032f3a0d-ef41-4ad6-8a52-90e2f3059f59" x1="459.55" y1="723.31" x2="459.55" y2="473.18" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/></defs><title>secure server</title><g opacity="0.8"><rect x="455" width="411" height="458.72" fill="url(#01871234-c547-4dad-9a9e-117fcc2bb7d8)"/></g><rect x="461.79" y="306.27" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="340.06" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="376.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="158.26" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="192.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="228.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="10.25" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="44.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="80.08" r="40.06" fill="url(#705039c9-bbaf-489f-8754-0d88ff1d230d)"/><circle cx="802.94" cy="229.08" r="40.06" fill="url(#f1b18cc7-88bb-4220-8574-cb67e65e6847)"/><circle cx="802.94" cy="377.08" r="40.06" fill="url(#729136a4-2a29-4999-b297-e6b2d53009e3)"/><circle cx="802.94" cy="80.08" r="37.16" fill="var(--main-accent)"/><g opacity="0.8"><path d="M750.13,785.18H200.07V434a15.8,15.8,0,0,1,15.8-15.8H734.32a15.8,15.8,0,0,1,15.8,15.8Z" transform="translate(-167 -89.81)" fill="url(#7a22862c-2f66-4029-a70d-d91858435973)"/></g><path d="M56.45,334.85h503.3a15.8,15.8,0,0,1,15.8,15.8V695.37a0,0,0,0,1,0,0H40.65a0,0,0,0,1,0,0V350.65A15.8,15.8,0,0,1,56.45,334.85Z" fill="#fff"/><rect x="57.87" y="355.82" width="500.46" height="328.25" fill="#f5f5f5"/><rect x="59.79" y="371.76" width="498.34" height="295.32" fill="#e0e0e0" opacity="0.8"/><rect x="59.79" y="356.07" width="498.34" height="15.69" fill="#f5f5f5"/><circle cx="70.47" cy="363.67" r="3.8" fill="#ff5252"/><circle cx="80.93" cy="363.67" r="3.8" fill="#ff0"/><circle cx="91.4" cy="363.67" r="3.8" fill="#69f0ae"/><text x="-167" y="-89.81"></text><g opacity="0.8"><path d="M765.52,808.77c-30.62,2.32-556.46,1.39-574.84,0C166.59,Line truncated
|
||||
'robot':`<svg id="fd25c83a-acc9-441d-8dce-2bbb37970907" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1076.4" height="733.47" viewBox="0 0 1076.4 733.47"><defs><linearGradient id="bc224463-365b-4973-a542-782a8bb75c72" x1="487.42" y1="814.54" x2="487.42" y2="138.95" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="f10c32a2-f99a-4cbc-971d-c4e860c06767" x1="875.73" y1="813.03" x2="875.73" y2="187.56" gradientTransform="matrix(-1, 0, 0, 1, 1751.04, 0)" xlink:href="#bc224463-365b-4973-a542-782a8bb75c72"/></defs><title>robotics</title><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5.68-54.85q0-3.23,0-6.45c0-7.45-.11-14.95-.24-22.44,0-1.94-.1-3.88-.14-5.83-.13-4.94-.3-9.9-.54-14.84-.06-1.37-.13-2.75-.21-4.11,0-.79-.08-1.58-.14-2.37Q1130.71,368.61,1130.38,364.35Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,240.27h50.44a343.41,343.41,0,0,0-26.83-32.84c-7.49-8.18-15.37-16.16-23.61-23.84Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,243.72V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42A201.46,201.46,0,0,0,1108.39,273a215,215,0,0,0-17.1-29.29Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M820.1,364.35V475.79h196.38V364.35Zm0,114.88V554.7c22.86-7.31,49-4.35,72.7,1.65,18.35,4.65,36.23,11,54.55,15.55a198.26,198.26,0,0,0,24.87,4.7l2.06.23,3,.27,3,.22a1.67,1.67,0,0,1,.31,0c.44,0,.89,0,1.34.06l.46,0c.54,0,1.08.06,1.62.06h0c.77,0,1.55,0,2.33.07h1c.77,0,1.55,0,2.33,0l1.71,0,1.38,0c.59,0,1.17,0,1.75-.06.94,0,1.88-.08,2.83-.14h0l1.75-.12c.72,0,1.45-.1,2.16-.17,1.53-.12,3-.27,4.55-.46.6-.07,1.21-.14,1.81-.23l.89-.11c1.51-.2,3-.43,4.51-.69,1.11-.17,2.22-.37,3.34-.58V479.23Zm146.06-239v3.44H820.1V360.9h196.38V243.71h-9.85v-3.44h9.85V164.54c-38.2-30.78-82.85-54.88-132.58-66a282.59,282.59,0,0,0-63.8-6.66V240.27Zm-449-.66.69.66H798.13V92.91c-49.9,4.15-98.56,20.91-137.79,48.68-18.85,13.33-35.78,29.14-56.93,39.36a89.15,89.15,0,0,1-35.6,9.2Zm-1.93-60.44v33.35l27.08-26.43C532.24,182.57,524.3,180.2,515.19,179.17Zm-.9,419.56.9,0V671.8q15.33-1.15,30.54-3.14A547.36,547.36,0,0,0,678,634.4h0q6.84-2.76,13.58-5.67,4.49-2,8.93-4,6.66-3.06,13.22-6.29A463.75,463.75,0,0,0,769.62,586c1.11-.76,2.23-1.52,3.33-2.29q3.31-2.3,6.59-4.64c1.09-.79,2.18-1.57,3.26-2.37l2.25-1.66c2.24-1.67,4.47-3.34,6.73-5h0c2.09-1.5,4.2-3,6.34-4.4v-86.4H572.94c.16-.5.31-1,.48-1.53-.18.51-.34,1-.49,1.53H515.19V598.67Zm.9-355V360.9H798.13V243.71Zm0,120.64V475.79H798.13V364.35ZM458.67,182.56l34.56,33.73V179Q475.86,180.08,458.67,182.56ZM218,364.35V475.79H493.23V364.35Zm66.52,275.24a511.15,511.15,0,0,0,129.73,30.78l.19,0q12.58,1.33,25.25,2.06l.31,0h0c17.69,1,35.45,1.23,53.18.57V479.23H218l1,128.32M141.79,475.79h54.28V364.35H174.6c-2.23,3.91-4.39,7.86-6.44,11.85-1.1,2.13-2.17,4.29-3.21,6.45C150.82,412,141.84,444.22,141.79,475.79Zm0,3.44c.44,22.64,5.54,44.87,16.73,65.4,9.3,17,22.22,32,37.51,45V479.23ZM176.61,360.9h19.46v-29A310.66,310.66,0,0,0,176.61,360.9ZM298.5,240.27H486.74l.68-.66-1-1h0L433.49,187A427.49,427.49,0,0,0,387,199.43c-1.44.47-2.88,1-4.31,1.45A422,422,0,0,0,298.5,240.27ZM218,305.64V360.9H459.38l0,0,0,0h33.83v-114l-3.86-3.14H293A378.08,378.08,0,0,0,218,305.64Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,302.83V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5Line truncated
|
||||
'icecream':`<svg id="acfec7e7-ff55-4f51-a7da-3052482bb043" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="814.10215" height="785" viewBox="0 0 814.10215 785"><title>ice_cream</title><path d="M663.893,822.88894l-229.21475.7592c-60.81041-18.2721-110.6323-81.736-110.94745-176.88721a269.465,269.465,0,0,1,3.23812-42.81223L511.59074,562.471l142.42432-31.995a283.262,283.262,0,0,1,29.2563,30.4193L547.08072,632.80761,482.83355,666.7338,555.028,641.82691l149.84346-51.69428a363.37031,363.37031,0,0,1,24.91622,44.67247l-45.211,46.72484L735.721,648.04757q.32788.76935.653,1.54117C768.78735,726.53874,728.57068,822.67471,663.893,822.88894Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><path d="M626.22411,508.93981c-34.66294,9.9552-77.27875,22.53336-121.09467,35.60691C441.778,563.45453,375.92052,583.40809,327.9742,597.99381l.0313-.171a65.89939,65.89939,0,0,1,12.15994-28.22451c35.51716-46.17886,80.74259-78.4385,131.07659-90.4187h.00442a188.90614,188.90614,0,0,1,43.20151-5.1543C554.49386,473.89268,592.42573,486.45281,626.22411,508.93981Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><ellipse cx="284.72861" cy="762.5" rx="156" ry="22.5" fill="#3f3d56"/><polygon points="229.229 552.5 229.229 587.5 254.229 590.5 269.229 571.5 257.229 547.5 229.229 552.5" fill="#fbbebe"/><polygon points="324.229 696.5 337.229 731.5 361.229 723.5 343.229 694.5 324.229 696.5" fill="#fbbebe"/><path d="M487.17753,456s-130-13-126,23,61,139,61,139l38-7s-27-42-25-56-6-33-6-33l19,3,11,17,23,118,35,106,26-7s-14-44-10-58-19-68-19-68l16-94s31-9,22-40S487.17753,456,487.17753,456Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M433.17753,634s-15-12-14-7a35.88236,35.88236,0,0,1,0,11l-14,31s-34,31-3,32,47-31,47-31,43-32,38-37-31.24173-16.48346-31.24173-16.48346S447.17753,641,433.17753,634Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M529.17753,773s-14-13-15-7-2,14-2,14l-21,20s-30,29-4,30a50.27149,50.27149,0,0,0,40-17s38-8,36-14-12.02812-29.212-16.51406-30.106S541.17753,781,529.17753,773Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><circle cx="288.22861" cy="223.5" r="31" fill="#fbbebe"/><path d="M467.17753,302s13,35,12,38,41-25,41-25-18-4-18-20Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M581.17753,353s18,18,21,24,71,82,71,82,45,53,27,54-41-42-41-42-64-47-67-58l-37-38Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M487.17753,332h-11l-15,24s-15,13-4,40,20,61,20,61,32,4,38,14,40,42,40,42,6-21,4-52,0-78,0-78l-2-62s-38.62342-8.95238-41.31171-7.47619S487.17753,332,487.17753,332Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M542.17753,324l15-3s16,10,31,39c0,0-32,21-35,20S542.17753,324,542.17753,324Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M450.17753,392s-23,37-16,51,16,13,16,13,17-3,17-17,7-15,7-15,44-36,27-36-40,25-40,25l-1-23Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M465.17753,355s-15,4-18,20-2,24-2,24l20-2Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M437.17753,435s16-21,18-18Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M496.92557,243.47071s-16.4399-16.42593-32.26693-5.39712-23.19733,21.533-21.43077,28.66652,6.70064,16.9912,6.70064,16.9912-3.29287-14.78823,10.01317-16.23678,31.49853-10.75491,31.49853-10.75491l13.15752,26.28707s1.60057-6.21312,6.53114-4.57193S511.35516,245.06778,496.92557,243.47071Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M384.43046,793.39447c0,14.58087-8.6676,19.672-19.36056,19.672q-.37157,0-.742-.00826c-.49543-.01062-.98615-.0342-1.47095-.06724-9.6502-.683-17.14766-6.03594-17.14766-19.59647,0-14.03354,17.93326-31.7415,19.28034-33.052l.00236-.00236c.05191-.05072.07786-.07549.07786-.07549S384.43046,778.8136,384.43046,793.39447Z" transform="translate(-192.94892 -57.5)" fill="var(--main-accent)"/><path d="M364.36451,810.83584l7.08105-9.89437-7.09875,10.98077-.01886,1.13594c-.49543-.01062-.98615-.0342-1.47095-.06724l.7632-14.58794-.0059-.11325.013-.02123.072-1.37775-7.11645-11.00791,7.13886,9.97459.01653.29253.5768-11.02205-6.09255-11.37476,6.16687,9.44024.6004-22.851.00236-.07785v.07549l-.10026,18.01937,6.06544-7.14358-6.0902,8.69591-.16043,9.86842,5.6632-9.4709-5.68678,10.923-.08965,5.48625,8.22171-13.18188L364.56269,798.633Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M824.25561,277.1656s-6.15258,19.13-15.75329,46.61588c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561-5.22251,14.36534-10.97441,29.73228-16.99808,45.09922-3.70576,9.45775-7.51178,18.90109-11.36076,28.12986-20.68952,49.46327-42.72393,92.3161-57.161,92.91711-32.43645,1.345-101.91689-215.681-109.50028-239.66146-.44364-1.40225-.67244-2.14622-.67244-2.14622Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M808.50232,323.78148c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561L712.25139,277.1656h43.31079Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M78Line truncated
|
||||
'watching':`<svg id="bea18b66-86ba-42fe-9190-76147bd64da7" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1057" height="784.44" viewBox="0 0 1057 784.44"><defs><linearGradient id="e9b7614d-2d40-4533-bad7-5d0811b316bd" x1="890.27" y1="819.43" x2="890.27" y2="453.22" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient></defs><title>meditating</title><g opacity="0.2"><path d="M719.61,602a23.27,23.27,0,0,1-2.11,3.25Q707,597.9,696.12,590.78l.47-.93a20.53,20.53,0,0,1,7.27-7.92c4-2.46,8.51-3,12.22-1s5.78,6,6,10.67A20.6,20.6,0,0,1,719.61,602Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="717.85" cy="577.53" rx="17.05" ry="13.02" transform="translate(-200 883.82) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="727.6" cy="559.12" rx="17.05" ry="13.02" transform="translate(-178.54 882.64) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="737.35" cy="540.71" rx="17.05" ry="13.02" transform="translate(-157.08 881.47) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="747.1" cy="522.3" rx="17.05" ry="13.02" transform="translate(-135.63 880.29) rotate(-62.09)" fill="#3f3d56"/><path d="M770.75,373.5a61.7,61.7,0,0,1-1-8.58L802.67,376l-32.83-17.08a62.45,62.45,0,0,1,22.08-44.23l31.81,46L799,309.49a62.37,62.37,0,1,1,58.36,109.89,62.4,62.4,0,0,1,1,13.39l-50-.4,49.28,8.12a62.37,62.37,0,0,1-36.29,47.05,62.37,62.37,0,1,1-86.65-45.89,62.39,62.39,0,0,1,36.1-68.15Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M851.14,459.71a62.14,62.14,0,0,1-29.84,27.83,62.37,62.37,0,1,1-86.65-45.89C732.23,428.36,854.42,453.52,851.14,459.71Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><g opacity="0.2"><path d="M288.48,716.67a43.27,43.27,0,0,0,2.39,7.05q23.11-8.85,46.75-17c-.14-.64-.28-1.3-.44-2a39.53,39.53,0,0,0-9.83-18.22c-6.29-6.48-14.45-9.64-22.3-7.72s-13.63,8.47-16.24,17.09A39.55,39.55,0,0,0,288.48,716.67Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="303.33" cy="671.75" rx="25.07" ry="32.81" transform="translate(-222.12 33.29) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="293.82" cy="632.78" rx="25.07" ry="32.81" transform="translate(-213.16 29.93) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="284.31" cy="593.82" rx="25.07" ry="32.81" transform="translate(-204.19 26.56) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="274.8" cy="554.86" rx="25.07" ry="32.81" transform="translate(-195.22 23.2) rotate(-13.72)" fill="#3f3d56"/><path d="M300.88,266a118.65,118.65,0,0,0,5.81-15.56l-66.55,5.11,69.33-16.38a120.32,120.32,0,0,0-20.33-93L208.05,217l70.26-83.83A120.07,120.07,0,1,0,117.52,310.72a119.33,119.33,0,0,0-8.11,24.52L203,358.12,107.18,350A120.14,120.14,0,0,0,152.72,455a120.07,120.07,0,1,0,183.37-44.75A120.08,120.08,0,0,0,300.88,266Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M110.16,388.94a119.51,119.51,0,0,0,42.56,66,120.07,120.07,0,1,0,183.37-44.75C346.88,386.55,107,375.85,110.16,388.94Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><ellipse cx="532" cy="711.44" rx="525" ry="73" fill="var(--main-accent)" opacity="0.1"/><rect x="138" width="568" height="684" rx="19.8" fill="#464353"/><rect x="187" y="69" width="470" height="546" fill="var(--main-accent)" opacity="0.1"/><circle cx="422" cy="651" r="20" fill="#fff"/><circle cx="422" cy="40" r="9.5" fill="#fff"/><path d="M493.5,88.78a9,9,0,1,1-9,9,9,9,0,0,1,9-9m0-1a10,10,0,1,0,10,10,10,10,0,0,0-10-10Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M393.5,517.67V499.6h2.06v8.67l9.16-8.48a.79.79,0,0,1,.52-.19.86.86,0,0,1,.87.87V516.8a.86.86,0,0,1-.87.87.8.8,0,0,1-.54-.21l-9.14-8.41v8.62Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M405.24,500.09a.36.36,0,0,1,.37.37V516.8a.37.37,0,0,1-.58.29l-8.3-7.63-.87-.81.88-.8,8.29-7.68a.37.37,0,0,1,.21-.08m-10.18,0v17.07H394V500.09h1.07m10.18-1a1.33,1.33,0,0,0-.86.32l-8.32,7.7V499.6a.51.51,0,0,0-.51-.51H393.5a.51.51,0,0,0-.51.51v18a.51.51,0,0,0,.51.51h2.05a.51.51,0,0,0,.51-.51v-7.46l8.32,7.66a1.32,1.32,0,0,0,.86.31,1.35,1.35,0,0,0,1.37-1.36V500.46a1.36,1.36,0,0,0-1.37-1.37Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M591.46,517.67V509l-9.16,8.48a.82.82,0,0,1-.53.19.86.86,0,0,1-.6-.25.82.82,0,0,1-.27-.62V500.46a.87.87,0,0,1,1.42-.67l9.14,8.42V499.6h2l0,18.06,0,0Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M581.76,500.09a.32.32,0,0,1,.21.08l8.3,7.63.87.8-.87.81-8.3,7.68a.31.31,0,0,1-.21.07.36.36,0,0,1-.37-.36V500.46a.37.37,0,0,1,.37-.37m11.26,0v17.07H592V500.09H593m-11.26-1a1.36,1.36,0,0,0-1.37,1.37V516.8a1.36,1.36,0,0,0,1.37,1.36,1.33,1.33,0,0,0,.87-.31l8.32-7.71v7.51a.51.51,0,0,0,.51.51h2a.51.51,0,0,0,.52-.51v-18a.51.51,0,0,0-.52-.51h-2a.51.51,0,0,0-.51.51v7.47l-8.32-7.66a1.34,1.34,0,0,0-.87-.32Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M496.9,518a1.54,1.54,0,0,1-1.Line truncated
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted(){
|
||||
|
||||
},
|
||||
methods: {
|
||||
onMouseUp(){
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style type="text/css" scoped>
|
||||
.svg-holder >>> svg {
|
||||
width: 100%;
|
||||
max-height: 250px;
|
||||
}
|
||||
<template>
|
||||
<div class="svg-holder" v-if="svg[file]" v-html="svg[file]" :alt="alt"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props:[ 'file','alt' ],
|
||||
data() {
|
||||
return {
|
||||
//Remove title, remove id from each SVG
|
||||
svg: {
|
||||
'idea':'<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="1013.02965" height="844.99456" viewBox="0 0 1013.02965 844.99456"><path d="M1050.48389,297.81272c0,35.17-8.33,64.12-22.7799,88.92a177.67758,177.67758,0,0,1-16.19,23.25c-53.12,64.97-151.88,100.53-241.14,158.13-263.48,170.02-292.02-102.42-280.12-270.3,4.8-67.74,31.18-129.79,73.09-177.3a278.22062,278.22062,0,0,1,28.45-27.97c47.58-40.52,109.05-65.04,178.58-65.04,119.29,0,221.16,71.95,261.55,173.34a261.84957,261.84957,0,0,1,18.55993,96.97Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M830.054,88.4227l-266.71,32.09a278.22062,278.22062,0,0,1,28.45-27.97Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1027.704,386.7327a177.67758,177.67758,0,0,1-16.19,23.25l-228.46-27.48Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1041.2439,228.72269l-387.18994-50.22,377.87,22.34A260.439,260.439,0,0,1,1041.2439,228.72269Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04a27.99706,27.99706,0,1,1-52.68-15.11,25.73956,25.73956,0,0,1,2.06-3.31,27.9889,27.9889,0,0,1,50.6,14.1C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M516.51853,99.50486c0,.69-.02,1.37-.08,2.04l-41.92-5.04,41.9.72C516.4885,97.97483,516.51853,98.73484,516.51853,99.50486Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M506.51853,113.61484l-38.78,4.67a28.05694,28.05694,0,0,1-2.96-3.95Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M495.51853,82.61484l-31.76,3.82a25.73956,25.73956,0,0,1,2.06-3.31Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443a27.99706,27.99706,0,1,1-39.45-38.042,25.73938,25.73938,0,0,1,3.37127-1.9581,27.98891,27.98891,0,0,1,38.08621,36.17477C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M900.25143,603.63055c-.32346.60949-.65991,1.20075-1.027,1.76443L864.5584,581.29206l36.67354,20.27763C900.94214,602.265,900.61239,602.95037,900.25143,603.63055Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M884.80385,611.4064l-36.44426-14.054a28.05745,28.05745,0,0,1-.76292-4.87668Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M889.61936,578.867l-29.8449-11.514a25.73938,25.73938,0,0,1,3.37127-1.9581Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163a27.99707,27.99707,0,1,1-48.26355,25.96387,25.73851,25.73851,0,0,1-.83628-3.80793,27.9889,27.9889,0,0,1,46.06931-25.23471C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="var(--main-accent)"/><path d="M1098.56381,120.95526c.48176.494.9422.99477,1.367,1.5163l-33.52955,25.66038,30.499-28.73915C1097.47405,119.88088,1098.02619,120.404,1098.56381,120.95526Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1101.25637,138.03864l-24.50216,30.41946a28.0572,28.0572,0,0,1-4.87694-.76118Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M1071.73725,123.52584l-20.07,24.90959a25.73851,25.73851,0,0,1-.83628-3.80793Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M573.26553,636.95591s61.44557,2.56024,61.44557,17.92163-69.12627,0-69.12627,0Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M481.09717,872.49728l-115.21045-7.6807L360.76625,803.371l-25.60232,53.76488-112.65022-15.3614c2.56023-97.28882,43.524-192.01741,46.08418-204.81858s8.67923-65.69564,8.67923-65.69564c11.1882-23.73325,69.81727-25.96064,115.77362-23.81013,19.04829.87055,35.94576,2.509,46.36577,3.68674,6.88688.768,10.95787,1.33137,10.95787,1.33137,18.35667,18.92015,30.18524,44.70155,37.45621,73.50417C514.04734,729.73871,481.09717,872.49728,481.09717,872.49728Z" transform="translate(-93.48517 -27.50272)" fill="#2f2e41"/><circle cx="259.60038" cy="187.01487" r="64.00581" fill="#a0616a"/><path d="M309.56161,229.879S291.64,309.24619,263.47743,332.28828s110.09,0,110.09,0-30.72278-51.20465-7.68069-74.24674S309.56161,229.879,309.56161,229.879Z" transform="translate(-93.48517 -27.50272)" fill="#a0616a"/><path d="M440.13345,557.58871,397.659,562.29945,278.83882,575.51034s-.56318-1.51061-1.5617-4.25007c-8.628-23.52854-50.18049-138.022-54.76341-172.406-5.12046-38.40349,48.64441-84.48767,48.64441-84.48767s51.20465-10.24093,66.566-10.24093,76.807,23.04209,76.807,23.04209l22.914,206.12433,1.97143,17.84474Z" transform="translate(-93.48517 -27.50272)" fill="#d0cde1"/><path d="M487.83059,625.97242,401.73,575.51034s-1.51076-4.91576-4.071-13.21089c-1.28011-4.14756-2.8415-9.16551-4.60823-14.84931-9.93371-31.90055-26.60085-84.53892-39.96518-122.9935-20.48186-58.88534,79.36719,104.96952,79.36719,104.96952lLine truncated
|
||||
'onboarding':`<svg id="70bb2b30-be60-4de4-9e4b-86e23973444e" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1143.11" height="875.78" viewBox="0 0 1143.11 875.78"><defs><linearGradient id="e5da6213-051c-4da8-b1b3-926fff123e76" x1="539.33" y1="763.29" x2="539.33" y2="57.83" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="052f7d93-657d-41ac-9ec0-ae0276bba283" x1="1034.54" y1="812.84" x2="1034.54" y2="197.81" gradientTransform="matrix(-1, 0, 0, 1, 1347.17, 0)" xlink:href="#e5da6213-051c-4da8-b1b3-926fff123e76"/></defs><title>onboarding</title><ellipse cx="849.67" cy="829.19" rx="115.8" ry="28.95" fill="var(--main-accent)" opacity="0.1"/><path d="M63.7,616.77c-13.1,4.49-29,13.65-26.32,28.21,1.34,7.38,7.32,12.85,13,17.47a368.3,368.3,0,0,0,79.36,49.15c13.57,6.16,28,11.81,38.25,23.17s15,27.24,18.33,42.55a345.86,345.86,0,0,1,5.72,110.41c40.91.78,86.21-.14,115.16-29.61,17.33-17.65,26.62-43.83,47-57.49,13.59-9.11,30.34-11.37,46.52-13.27q114.87-13.43,230.93-16.26c32.42-.79,65.55-1.07,95.82-12.37s57.75-36,63.9-69.49c.95-5.16,1.57-10.85,5.1-14.45,3.15-3.23,7.87-4,12.28-4.57l265.89-36c14.56-2,29.63-4.13,41.89-12.36,11.86-8,19.86-20.78,27.51-33.24,17.53-28.52,35.72-63,23.58-95.61-9.71-26.06-35.81-41.07-60.75-50.39s-51.79-15.87-72.06-34.14c-30.84-27.8-37.68-73.95-46.37-115.36a675.43,675.43,0,0,0-33-109.89C943,151.89,927.85,121,905.27,96.61S851.62,54.93,820,56.17C778.7,57.81,742.37,90,701,86c-34.94-3.35-63.37-31.35-94.83-48.77C550.32,6.28,484.4,9,423.29,18.65c-64.8,10.21-130,28.08-182.5,68.27-12,9.2-23.48,19.82-30.53,33.66-10.8,21.23-9.87,47.1-15.28,70.69-21.8,95-138.68,134.63-162.71,228.94C23.73,453.69,30.05,490,45.72,520.94c16.85,33.31,50.7,47.51,68.05,76.1C102.17,607.63,79.06,611.5,63.7,616.77Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)" opacity="0.1"/><path d="M898.6,849s-10.48-72.2,21-119.72a110.5,110.5,0,0,0,18.68-67.38,185.42,185.42,0,0,0-5.1-33.61" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M952.92,598.2c-1.21,8.49-20.11,30.91-20.11,30.91S921,602.28,922.18,593.79a15.53,15.53,0,1,1,30.74,4.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M971.6,648.87c-5.64,6.46-33.69,15-33.69,15s4.64-29,10.27-35.42a15.53,15.53,0,1,1,23.42,20.41Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M959,729.07c-8.13,2.74-36.66-4-36.66-4s18.62-22.66,26.75-25.39A15.53,15.53,0,0,1,959,729.07Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M935.76,787.61c-7.55,4.07-36.81,2.2-36.81,2.2s14.52-25.47,22.07-29.54a15.53,15.53,0,0,1,14.74,27.34Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M905.4,660.9c5.24,6.79,32.74,17,32.74,17s-2.92-29.18-8.16-36a15.53,15.53,0,0,0-24.58,19Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M879.12,729.7c7,4.91,36.31,6.41,36.31,6.41s-11.5-27-18.54-31.88a15.53,15.53,0,1,0-17.77,25.47Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M862.75,800.08c6.1,6,34.71,12.45,34.71,12.45S890.68,784,884.57,778a15.53,15.53,0,1,0-21.82,22.1Z" transform="translate(-28.45 -12.11)" opacity="0.25"/><path d="M899.46,846.71s-38.84-61.76-29.27-118A110.5,110.5,0,0,0,860,659.56a185.49,185.49,0,0,0-18.29-28.66" transform="translate(-28.45 -12.11)" fill="none" stroke="#535461" stroke-miterlimit="10" stroke-width="2"/><path d="M847.51,595.43c2.32,8.26-5.87,36.41-5.87,36.41s-21.7-19.72-24-28a15.53,15.53,0,1,1,29.9-8.44Z" transform="translate(-28.45 -12.11)" fill="var(--main-accent)"/><path d="M885.11,634.19c-2.53,8.19-24.71,27.37-24.71,27.37sLine truncated
|
||||
'cloud':`<svg id="b75ffde3-65ff-4761-a8e9-ebaeba35e9db" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="863.67584" height="787.45556" viewBox="0 0 863.67584 787.45556"><title>cloud_docs</title><rect x="674.64346" y="434.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="281.39412" width="2" height="140" fill="#f2f2f2"/><rect x="674.64346" y="87.39412" width="2" height="140" fill="#f2f2f2"/><circle cx="675.64346" cy="434.39412" r="25" fill="#f2f2f2"/><circle cx="675.64346" cy="267.39412" r="47" fill="#f2f2f2"/><circle cx="675.64346" cy="87.39412" r="63" fill="#f2f2f2"/><circle cx="650.64346" cy="59.39412" r="10" fill="#d0cde1"/><circle cx="690.64346" cy="81.39412" r="6" fill="#d0cde1"/><circle cx="675.64346" cy="113.39412" r="6" fill="#d0cde1"/><path d="M558.5736,732.72005a443.16545,443.16545,0,0,1-168.1455-33.12793A356.65064,356.65064,0,0,1,194.67956,234.2889,285.75854,285.75854,0,0,1,567.183,77.58041,228.807,228.807,0,0,1,692.65905,375.84359,183.245,183.245,0,0,1,453.788,476.33431a146.79491,146.79491,0,0,1-80.502-191.35742,117.77062,117.77062,0,0,1,153.34668-64.51123,94.41622,94.41622,0,0,1,51.71875,122.938A75.64668,75.64668,0,0,1,479.7406,384.888a60.71786,60.71786,0,0,1-33.29786-79.14941A48.831,48.831,0,0,1,510.02331,278.991a39.264,39.264,0,0,1,21.50781,51.125,31.612,31.612,0,0,1-41.16064,17.31641,25.4898,25.4898,0,0,1-13.9624-33.19,20.59,20.59,0,0,1,26.812-11.27929,16.67345,16.67345,0,0,1,9.1333,21.71,13.5396,13.5396,0,0,1-17.62891,7.4165,11.03155,11.03155,0,0,1-6.043-14.36377,9.02471,9.02471,0,0,1,11.752-4.94384,7.42021,7.42021,0,0,1,4.06445,9.66211,6.13434,6.13434,0,0,1-7.99023,3.36132,5.10967,5.10967,0,0,1-2.79883-6.65283,4.283,4.283,0,0,1,5.583-2.34863,3.62962,3.62962,0,0,1,1.98877,4.72705,3.10075,3.10075,0,0,1-4.043,1.70019,2.67971,2.67971,0,0,1-1.46924-3.49462,2.34588,2.34588,0,0,1,.52344-.78467,2.2689,2.2689,0,0,0-.73145.95556,3.10864,3.10864,0,0,0,1.70215,4.04639,4.13478,4.13478,0,0,0,5.38379-2.26465,5.41865,5.41865,0,0,0-2.96826-7.05566,7.02632,7.02632,0,0,0-9.145,3.84717,9.0309,9.0309,0,0,0,4.94629,11.75732,11.54031,11.54031,0,0,0,15.02246-6.31982,14.67283,14.67283,0,0,0-8.03662-19.10352,18.58956,18.58956,0,0,0-24.20557,10.18262,23.4616,23.4616,0,0,0,12.86572,30.583,29.61209,29.61209,0,0,0,38.5542-16.21924,37.26339,37.26339,0,0,0-20.41113-48.51856,46.83052,46.83052,0,0,0-60.97412,25.65088A58.718,58.718,0,0,0,480.49548,383.036a73.64788,73.64788,0,0,0,96.00439-40.38721A92.41557,92.41557,0,0,0,525.8778,222.31722a115.77022,115.77022,0,0,0-150.74023,63.41455,144.79736,144.79736,0,0,0,79.40527,188.751,181.24485,181.24485,0,0,0,236.26465-99.394A226.80605,226.80605,0,0,0,566.4281,79.43246C421.5448,20.35384,255.60974,90.161,196.53112,235.04378,122.63122,416.27523,209.952,623.84017,391.183,697.74056c226.666,92.42676,486.269-16.78515,578.69678-243.45215l1.85156.75586A446.43912,446.43912,0,0,1,564.39,732.68294C562.45153,732.70736,560.50915,732.72005,558.5736,732.72005ZM497.93982,320.27718a.3519.3519,0,0,0-.13428.02735.34584.34584,0,0,0-.18555.1875.6803.6803,0,0,0,.37305.8872,1.10164,1.10164,0,0,0,1.436-.603,1.62634,1.62634,0,0,0-.7207-2.03858l-.63916,1.56543A.33875.33875,0,0,0,497.93982,320.27718Z" transform="translate(-168.16208 -56.27222)" fill="#3f3d56"/><circle cx="489.56553" cy="232.87527" r="49.53794" fill="#2f2e41"/><polygon points="544.143 380.894 550.143 399.894 472.143 411.894 473.143 391.894 544.143 380.894" fill="#a0616a"/><path d="M621.30554,585.16634l-3,61s-17,31-15,75,1,45,1,45l-1,16,16,2v-21s23-58,25-87l23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M734.30554,585.16634l3,61s17,31,15,75-1,45-1,45l1,16-16,2v-21s-23-58-25-87l-23-87Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M718.30554,450.16634s-72,11-83,8l-36,125s33,13,73,9a34.86506,34.86506,0,0,1,24,4c12,7,48-12,48-12l25-5Z" transform="translate(-168.16208 -56.27222)" fill="#2f2e41"/><rect x="496.64953" y="407.40309" width="80" height="91" fill="var(--main-accent)"/><rect x="484.63738" y="417.38516" width="80" height="91" fill="#f2f2f2"/><path d="M605.30554,389.16634l-1,14,18,62s7,44,21,70c0,0,8,40,23,35s-7-44-7-44l-16-60-19-79Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><circle cx="493.64346" cy="250.39412" r="30" fill="#a0616a"/><path d="M644.30554,324.16634s9,27-9,32-36,16-32,34l2,4,125-30a27.43931,27.43931,0,0,0-27-13s-27.807-.73647-25.9035-18.86823l-3.0965-7.13177Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M726.30554,362.16634s5,0,18,17,22,26,22,26,12,6,5,20-32,28-32,28-25,30-28,15,21-23,21-23,18-17,17-27l-41-34Z" transform="translate(-168.16208 -56.27222)" fill="#a0616a"/><path d="M601.30554,387.16634s124-29,127-29l6.29076,9.74735s-1.29076,7.25265-16.29076,9.25265c0,0,3,15-3,30v37s-70,8-76,14l-10.77042-53.41383s-14.22958-8.58617-24.22958-1.58617C604.30554,403.16634,600.30554,390.16634,601.30554,387.16634Z" transform="translate(-168.16208 -56.27222)" fill="#d0cde1"/><path d="M621.30554,780.16634sLine truncated
|
||||
'secure':'<svg id="4687a0c2-bd42-4093-8bbc-92d10a889ded" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="866" height="720.37" viewBox="0 0 866 720.37"><defs><linearGradient id="01871234-c547-4dad-9a9e-117fcc2bb7d8" x1="660.5" y1="458.72" x2="660.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="705039c9-bbaf-489f-8754-0d88ff1d230d" x1="802.94" y1="120.14" x2="802.94" y2="40.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="f1b18cc7-88bb-4220-8574-cb67e65e6847" x1="802.94" y1="269.14" x2="802.94" y2="189.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="729136a4-2a29-4999-b297-e6b2d53009e3" x1="802.94" y1="417.14" x2="802.94" y2="337.02" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="7a22862c-2f66-4029-a70d-d91858435973" x1="475.1" y1="785.18" x2="475.1" y2="418.21" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="668716b8-0ce2-4b29-be6e-2467fe221975" x1="475.5" y1="810.19" x2="475.5" y2="750.5" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/><linearGradient id="032f3a0d-ef41-4ad6-8a52-90e2f3059f59" x1="459.55" y1="723.31" x2="459.55" y2="473.18" xlink:href="#01871234-c547-4dad-9a9e-117fcc2bb7d8"/></defs><title>secure server</title><g opacity="0.8"><rect x="455" width="411" height="458.72" fill="url(#01871234-c547-4dad-9a9e-117fcc2bb7d8)"/></g><rect x="461.79" y="306.27" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="340.06" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="340.06" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="376.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="158.26" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="192.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="192.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="228.09" r="37.16" fill="var(--main-accent)"/><rect x="461.79" y="10.25" width="397.41" height="142.46" fill="#fff"/><rect x="494.41" y="44.05" width="15.4" height="73" fill="#64ffda"/><g opacity="0.7"><rect x="524.81" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.6"><rect x="555.22" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="585.62" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.5"><rect x="616.02" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.4"><rect x="646.42" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><g opacity="0.3"><rect x="676.83" y="44.05" width="15.4" height="73" fill="#64ffda"/></g><circle cx="802.94" cy="80.08" r="40.06" fill="url(#705039c9-bbaf-489f-8754-0d88ff1d230d)"/><circle cx="802.94" cy="229.08" r="40.06" fill="url(#f1b18cc7-88bb-4220-8574-cb67e65e6847)"/><circle cx="802.94" cy="377.08" r="40.06" fill="url(#729136a4-2a29-4999-b297-e6b2d53009e3)"/><circle cx="802.94" cy="80.08" r="37.16" fill="var(--main-accent)"/><g opacity="0.8"><path d="M750.13,785.18H200.07V434a15.8,15.8,0,0,1,15.8-15.8H734.32a15.8,15.8,0,0,1,15.8,15.8Z" transform="translate(-167 -89.81)" fill="url(#7a22862c-2f66-4029-a70d-d91858435973)"/></g><path d="M56.45,334.85h503.3a15.8,15.8,0,0,1,15.8,15.8V695.37a0,0,0,0,1,0,0H40.65a0,0,0,0,1,0,0V350.65A15.8,15.8,0,0,1,56.45,334.85Z" fill="#fff"/><rect x="57.87" y="355.82" width="500.46" height="328.25" fill="#f5f5f5"/><rect x="59.79" y="371.76" width="498.34" height="295.32" fill="#e0e0e0" opacity="0.8"/><rect x="59.79" y="356.07" width="498.34" height="15.69" fill="#f5f5f5"/><circle cx="70.47" cy="363.67" r="3.8" fill="#ff5252"/><circle cx="80.93" cy="363.67" r="3.8" fill="#ff0"/><circle cx="91.4" cy="363.67" r="3.8" fill="#69f0ae"/><text x="-167" y="-89.81"></text><g opacity="0.8"><path d="M765.52,808.77c-30.62,2.32-556.46,1.39-574.84,0C166.59,Line truncated
|
||||
'robot':`<svg id="fd25c83a-acc9-441d-8dce-2bbb37970907" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1076.4" height="733.47" viewBox="0 0 1076.4 733.47"><defs><linearGradient id="bc224463-365b-4973-a542-782a8bb75c72" x1="487.42" y1="814.54" x2="487.42" y2="138.95" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient><linearGradient id="f10c32a2-f99a-4cbc-971d-c4e860c06767" x1="875.73" y1="813.03" x2="875.73" y2="187.56" gradientTransform="matrix(-1, 0, 0, 1, 1751.04, 0)" xlink:href="#bc224463-365b-4973-a542-782a8bb75c72"/></defs><title>robotics</title><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5.68-54.85q0-3.23,0-6.45c0-7.45-.11-14.95-.24-22.44,0-1.94-.1-3.88-.14-5.83-.13-4.94-.3-9.9-.54-14.84-.06-1.37-.13-2.75-.21-4.11,0-.79-.08-1.58-.14-2.37Q1130.71,368.61,1130.38,364.35Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,240.27h50.44a343.41,343.41,0,0,0-26.83-32.84c-7.49-8.18-15.37-16.16-23.61-23.84Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,243.72V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42A201.46,201.46,0,0,0,1108.39,273a215,215,0,0,0-17.1-29.29Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M820.1,364.35V475.79h196.38V364.35Zm0,114.88V554.7c22.86-7.31,49-4.35,72.7,1.65,18.35,4.65,36.23,11,54.55,15.55a198.26,198.26,0,0,0,24.87,4.7l2.06.23,3,.27,3,.22a1.67,1.67,0,0,1,.31,0c.44,0,.89,0,1.34.06l.46,0c.54,0,1.08.06,1.62.06h0c.77,0,1.55,0,2.33.07h1c.77,0,1.55,0,2.33,0l1.71,0,1.38,0c.59,0,1.17,0,1.75-.06.94,0,1.88-.08,2.83-.14h0l1.75-.12c.72,0,1.45-.1,2.16-.17,1.53-.12,3-.27,4.55-.46.6-.07,1.21-.14,1.81-.23l.89-.11c1.51-.2,3-.43,4.51-.69,1.11-.17,2.22-.37,3.34-.58V479.23Zm146.06-239v3.44H820.1V360.9h196.38V243.71h-9.85v-3.44h9.85V164.54c-38.2-30.78-82.85-54.88-132.58-66a282.59,282.59,0,0,0-63.8-6.66V240.27Zm-449-.66.69.66H798.13V92.91c-49.9,4.15-98.56,20.91-137.79,48.68-18.85,13.33-35.78,29.14-56.93,39.36a89.15,89.15,0,0,1-35.6,9.2Zm-1.93-60.44v33.35l27.08-26.43C532.24,182.57,524.3,180.2,515.19,179.17Zm-.9,419.56.9,0V671.8q15.33-1.15,30.54-3.14A547.36,547.36,0,0,0,678,634.4h0q6.84-2.76,13.58-5.67,4.49-2,8.93-4,6.66-3.06,13.22-6.29A463.75,463.75,0,0,0,769.62,586c1.11-.76,2.23-1.52,3.33-2.29q3.31-2.3,6.59-4.64c1.09-.79,2.18-1.57,3.26-2.37l2.25-1.66c2.24-1.67,4.47-3.34,6.73-5h0c2.09-1.5,4.2-3,6.34-4.4v-86.4H572.94c.16-.5.31-1,.48-1.53-.18.51-.34,1-.49,1.53H515.19V598.67Zm.9-355V360.9H798.13V243.71Zm0,120.64V475.79H798.13V364.35ZM458.67,182.56l34.56,33.73V179Q475.86,180.08,458.67,182.56ZM218,364.35V475.79H493.23V364.35Zm66.52,275.24a511.15,511.15,0,0,0,129.73,30.78l.19,0q12.58,1.33,25.25,2.06l.31,0h0c17.69,1,35.45,1.23,53.18.57V479.23H218l1,128.32M141.79,475.79h54.28V364.35H174.6c-2.23,3.91-4.39,7.86-6.44,11.85-1.1,2.13-2.17,4.29-3.21,6.45C150.82,412,141.84,444.22,141.79,475.79Zm0,3.44c.44,22.64,5.54,44.87,16.73,65.4,9.3,17,22.22,32,37.51,45V479.23ZM176.61,360.9h19.46v-29A310.66,310.66,0,0,0,176.61,360.9ZM298.5,240.27H486.74l.68-.66-1-1h0L433.49,187A427.49,427.49,0,0,0,387,199.43c-1.44.47-2.88,1-4.31,1.45A422,422,0,0,0,298.5,240.27ZM218,305.64V360.9H459.38l0,0,0,0h33.83v-114l-3.86-3.14H293A378.08,378.08,0,0,0,218,305.64Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1038.44,302.83V360.9h91.65c0-.34,0-.69-.09-1-.08-.93-.16-1.85-.25-2.77-.09-1.08-.21-2.17-.32-3.25s-.25-2.35-.39-3.52-.26-2.35-.41-3.52-.29-2.34-.45-3.5-.31-2.33-.48-3.49c-.14-1-.28-1.93-.43-2.89-.21-1.37-.43-2.73-.66-4.09,0-.2-.07-.41-.1-.6a253.15,253.15,0,0,0-6.75-29.42Z" transform="translate(-61.56 -83.02)" fill="var(--main-accent)" opacity="0.1"/><path d="M1130.38,364.35h-91.94V475.79h85.22c.22,1.15.44,2.3.68,3.45h-85.9v89.85l.43-.14c2.43-.85,4.84-1.75,7.23-2.72l1.55-.62q3.66-1.53,7.23-3.23c.84-.39,1.68-.8,2.51-1.22a150,150,0,0,0,36.17-25c.31-.26.6-.55.89-.82,1.46-1.41,2.9-2.85,4.3-4.32.52-.54,1-1.08,1.54-1.64,1.65-1.79,3.24-3.64,4.79-5.5.23-.28.45-.56.68-.82A115.92,115.92,0,0,0,1121.34,498c.13-.26.24-.55.36-.82,1.26-2.82,2.39-5.68,3.37-8.59.41-1.22.81-2.45,1.16-3.68.13-.37.23-.74.34-1.1,5.09-17.84,5.68-36.4,5Line truncated
|
||||
'icecream':`<svg id="acfec7e7-ff55-4f51-a7da-3052482bb043" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="814.10215" height="785" viewBox="0 0 814.10215 785"><title>ice_cream</title><path d="M663.893,822.88894l-229.21475.7592c-60.81041-18.2721-110.6323-81.736-110.94745-176.88721a269.465,269.465,0,0,1,3.23812-42.81223L511.59074,562.471l142.42432-31.995a283.262,283.262,0,0,1,29.2563,30.4193L547.08072,632.80761,482.83355,666.7338,555.028,641.82691l149.84346-51.69428a363.37031,363.37031,0,0,1,24.91622,44.67247l-45.211,46.72484L735.721,648.04757q.32788.76935.653,1.54117C768.78735,726.53874,728.57068,822.67471,663.893,822.88894Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><path d="M626.22411,508.93981c-34.66294,9.9552-77.27875,22.53336-121.09467,35.60691C441.778,563.45453,375.92052,583.40809,327.9742,597.99381l.0313-.171a65.89939,65.89939,0,0,1,12.15994-28.22451c35.51716-46.17886,80.74259-78.4385,131.07659-90.4187h.00442a188.90614,188.90614,0,0,1,43.20151-5.1543C554.49386,473.89268,592.42573,486.45281,626.22411,508.93981Z" transform="translate(-192.94892 -57.5)" fill="#f2f2f2"/><ellipse cx="284.72861" cy="762.5" rx="156" ry="22.5" fill="#3f3d56"/><polygon points="229.229 552.5 229.229 587.5 254.229 590.5 269.229 571.5 257.229 547.5 229.229 552.5" fill="#fbbebe"/><polygon points="324.229 696.5 337.229 731.5 361.229 723.5 343.229 694.5 324.229 696.5" fill="#fbbebe"/><path d="M487.17753,456s-130-13-126,23,61,139,61,139l38-7s-27-42-25-56-6-33-6-33l19,3,11,17,23,118,35,106,26-7s-14-44-10-58-19-68-19-68l16-94s31-9,22-40S487.17753,456,487.17753,456Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M433.17753,634s-15-12-14-7a35.88236,35.88236,0,0,1,0,11l-14,31s-34,31-3,32,47-31,47-31,43-32,38-37-31.24173-16.48346-31.24173-16.48346S447.17753,641,433.17753,634Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M529.17753,773s-14-13-15-7-2,14-2,14l-21,20s-30,29-4,30a50.27149,50.27149,0,0,0,40-17s38-8,36-14-12.02812-29.212-16.51406-30.106S541.17753,781,529.17753,773Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><circle cx="288.22861" cy="223.5" r="31" fill="#fbbebe"/><path d="M467.17753,302s13,35,12,38,41-25,41-25-18-4-18-20Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M581.17753,353s18,18,21,24,71,82,71,82,45,53,27,54-41-42-41-42-64-47-67-58l-37-38Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M487.17753,332h-11l-15,24s-15,13-4,40,20,61,20,61,32,4,38,14,40,42,40,42,6-21,4-52,0-78,0-78l-2-62s-38.62342-8.95238-41.31171-7.47619S487.17753,332,487.17753,332Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M542.17753,324l15-3s16,10,31,39c0,0-32,21-35,20S542.17753,324,542.17753,324Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M450.17753,392s-23,37-16,51,16,13,16,13,17-3,17-17,7-15,7-15,44-36,27-36-40,25-40,25l-1-23Z" transform="translate(-192.94892 -57.5)" fill="#fbbebe"/><path d="M465.17753,355s-15,4-18,20-2,24-2,24l20-2Z" transform="translate(-192.94892 -57.5)" fill="#575a89"/><path d="M437.17753,435s16-21,18-18Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M496.92557,243.47071s-16.4399-16.42593-32.26693-5.39712-23.19733,21.533-21.43077,28.66652,6.70064,16.9912,6.70064,16.9912-3.29287-14.78823,10.01317-16.23678,31.49853-10.75491,31.49853-10.75491l13.15752,26.28707s1.60057-6.21312,6.53114-4.57193S511.35516,245.06778,496.92557,243.47071Z" transform="translate(-192.94892 -57.5)" fill="#2f2e41"/><path d="M384.43046,793.39447c0,14.58087-8.6676,19.672-19.36056,19.672q-.37157,0-.742-.00826c-.49543-.01062-.98615-.0342-1.47095-.06724-9.6502-.683-17.14766-6.03594-17.14766-19.59647,0-14.03354,17.93326-31.7415,19.28034-33.052l.00236-.00236c.05191-.05072.07786-.07549.07786-.07549S384.43046,778.8136,384.43046,793.39447Z" transform="translate(-192.94892 -57.5)" fill="var(--main-accent)"/><path d="M364.36451,810.83584l7.08105-9.89437-7.09875,10.98077-.01886,1.13594c-.49543-.01062-.98615-.0342-1.47095-.06724l.7632-14.58794-.0059-.11325.013-.02123.072-1.37775-7.11645-11.00791,7.13886,9.97459.01653.29253.5768-11.02205-6.09255-11.37476,6.16687,9.44024.6004-22.851.00236-.07785v.07549l-.10026,18.01937,6.06544-7.14358-6.0902,8.69591-.16043,9.86842,5.6632-9.4709-5.68678,10.923-.08965,5.48625,8.22171-13.18188L364.56269,798.633Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M824.25561,277.1656s-6.15258,19.13-15.75329,46.61588c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561-5.22251,14.36534-10.97441,29.73228-16.99808,45.09922-3.70576,9.45775-7.51178,18.90109-11.36076,28.12986-20.68952,49.46327-42.72393,92.3161-57.161,92.91711-32.43645,1.345-101.91689-215.681-109.50028-239.66146-.44364-1.40225-.67244-2.14622-.67244-2.14622Z" transform="translate(-192.94892 -57.5)" fill="#3f3d56"/><path d="M808.50232,323.78148c-3.10475,8.92836-6.58171,18.7294-10.33044,29.04561L712.25139,277.1656h43.31079Z" transform="translate(-192.94892 -57.5)" opacity="0.1"/><path d="M78Line truncated
|
||||
'watching':`<svg id="bea18b66-86ba-42fe-9190-76147bd64da7" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1057" height="784.44" viewBox="0 0 1057 784.44"><defs><linearGradient id="e9b7614d-2d40-4533-bad7-5d0811b316bd" x1="890.27" y1="819.43" x2="890.27" y2="453.22" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient></defs><title>meditating</title><g opacity="0.2"><path d="M719.61,602a23.27,23.27,0,0,1-2.11,3.25Q707,597.9,696.12,590.78l.47-.93a20.53,20.53,0,0,1,7.27-7.92c4-2.46,8.51-3,12.22-1s5.78,6,6,10.67A20.6,20.6,0,0,1,719.61,602Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="717.85" cy="577.53" rx="17.05" ry="13.02" transform="translate(-200 883.82) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="727.6" cy="559.12" rx="17.05" ry="13.02" transform="translate(-178.54 882.64) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="737.35" cy="540.71" rx="17.05" ry="13.02" transform="translate(-157.08 881.47) rotate(-62.09)" fill="#3f3d56"/><ellipse cx="747.1" cy="522.3" rx="17.05" ry="13.02" transform="translate(-135.63 880.29) rotate(-62.09)" fill="#3f3d56"/><path d="M770.75,373.5a61.7,61.7,0,0,1-1-8.58L802.67,376l-32.83-17.08a62.45,62.45,0,0,1,22.08-44.23l31.81,46L799,309.49a62.37,62.37,0,1,1,58.36,109.89,62.4,62.4,0,0,1,1,13.39l-50-.4,49.28,8.12a62.37,62.37,0,0,1-36.29,47.05,62.37,62.37,0,1,1-86.65-45.89,62.39,62.39,0,0,1,36.1-68.15Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M851.14,459.71a62.14,62.14,0,0,1-29.84,27.83,62.37,62.37,0,1,1-86.65-45.89C732.23,428.36,854.42,453.52,851.14,459.71Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><g opacity="0.2"><path d="M288.48,716.67a43.27,43.27,0,0,0,2.39,7.05q23.11-8.85,46.75-17c-.14-.64-.28-1.3-.44-2a39.53,39.53,0,0,0-9.83-18.22c-6.29-6.48-14.45-9.64-22.3-7.72s-13.63,8.47-16.24,17.09A39.55,39.55,0,0,0,288.48,716.67Z" transform="translate(-71.5 -57.78)" fill="#3f3d56"/><ellipse cx="303.33" cy="671.75" rx="25.07" ry="32.81" transform="translate(-222.12 33.29) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="293.82" cy="632.78" rx="25.07" ry="32.81" transform="translate(-213.16 29.93) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="284.31" cy="593.82" rx="25.07" ry="32.81" transform="translate(-204.19 26.56) rotate(-13.72)" fill="#3f3d56"/><ellipse cx="274.8" cy="554.86" rx="25.07" ry="32.81" transform="translate(-195.22 23.2) rotate(-13.72)" fill="#3f3d56"/><path d="M300.88,266a118.65,118.65,0,0,0,5.81-15.56l-66.55,5.11,69.33-16.38a120.32,120.32,0,0,0-20.33-93L208.05,217l70.26-83.83A120.07,120.07,0,1,0,117.52,310.72a119.33,119.33,0,0,0-8.11,24.52L203,358.12,107.18,350A120.14,120.14,0,0,0,152.72,455a120.07,120.07,0,1,0,183.37-44.75A120.08,120.08,0,0,0,300.88,266Z" transform="translate(-71.5 -57.78)" fill="var(--main-accent)"/><path d="M110.16,388.94a119.51,119.51,0,0,0,42.56,66,120.07,120.07,0,1,0,183.37-44.75C346.88,386.55,107,375.85,110.16,388.94Z" transform="translate(-71.5 -57.78)" opacity="0.1"/></g><ellipse cx="532" cy="711.44" rx="525" ry="73" fill="var(--main-accent)" opacity="0.1"/><rect x="138" width="568" height="684" rx="19.8" fill="#464353"/><rect x="187" y="69" width="470" height="546" fill="var(--main-accent)" opacity="0.1"/><circle cx="422" cy="651" r="20" fill="#fff"/><circle cx="422" cy="40" r="9.5" fill="#fff"/><path d="M493.5,88.78a9,9,0,1,1-9,9,9,9,0,0,1,9-9m0-1a10,10,0,1,0,10,10,10,10,0,0,0-10-10Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M393.5,517.67V499.6h2.06v8.67l9.16-8.48a.79.79,0,0,1,.52-.19.86.86,0,0,1,.87.87V516.8a.86.86,0,0,1-.87.87.8.8,0,0,1-.54-.21l-9.14-8.41v8.62Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M405.24,500.09a.36.36,0,0,1,.37.37V516.8a.37.37,0,0,1-.58.29l-8.3-7.63-.87-.81.88-.8,8.29-7.68a.37.37,0,0,1,.21-.08m-10.18,0v17.07H394V500.09h1.07m10.18-1a1.33,1.33,0,0,0-.86.32l-8.32,7.7V499.6a.51.51,0,0,0-.51-.51H393.5a.51.51,0,0,0-.51.51v18a.51.51,0,0,0,.51.51h2.05a.51.51,0,0,0,.51-.51v-7.46l8.32,7.66a1.32,1.32,0,0,0,.86.31,1.35,1.35,0,0,0,1.37-1.36V500.46a1.36,1.36,0,0,0-1.37-1.37Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M591.46,517.67V509l-9.16,8.48a.82.82,0,0,1-.53.19.86.86,0,0,1-.6-.25.82.82,0,0,1-.27-.62V500.46a.87.87,0,0,1,1.42-.67l9.14,8.42V499.6h2l0,18.06,0,0Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M581.76,500.09a.32.32,0,0,1,.21.08l8.3,7.63.87.8-.87.81-8.3,7.68a.31.31,0,0,1-.21.07.36.36,0,0,1-.37-.36V500.46a.37.37,0,0,1,.37-.37m11.26,0v17.07H592V500.09H593m-11.26-1a1.36,1.36,0,0,0-1.37,1.37V516.8a1.36,1.36,0,0,0,1.37,1.36,1.33,1.33,0,0,0,.87-.31l8.32-7.71v7.51a.51.51,0,0,0,.51.51h2a.51.51,0,0,0,.52-.51v-18a.51.51,0,0,0-.52-.51h-2a.51.51,0,0,0-.51.51v7.47l-8.32-7.66a1.34,1.34,0,0,0-.87-.32Z" transform="translate(-71.5 -57.78)" fill="#fff"/><path d="M496.9,518a1.54,1.54,0,0,1-1.Line truncated
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
'':``,
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted(){
|
||||
|
||||
},
|
||||
methods: {
|
||||
onMouseUp(){
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style type="text/css" scoped>
|
||||
.svg-holder :deep(svg) {
|
||||
width: 100%;
|
||||
max-height: 250px;
|
||||
}
|
||||
</style>
|
||||
@@ -86,11 +86,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
|
||||
export default {
|
||||
components:{
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
props: [ 'lastUsedColor' ],
|
||||
data: function(){
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="update-prompt" v-if="visible">
|
||||
<span class="update-text">A new version is ready.</span>
|
||||
<div class="ui tiny green button" v-on:click="apply()">Update</div>
|
||||
<div class="ui tiny basic button" v-on:click="dismiss()">Later</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'UpdatePrompt',
|
||||
data(){
|
||||
return { visible: false }
|
||||
},
|
||||
// The service worker is configured with skipWaiting:false, so a new build
|
||||
// sits waiting until the user accepts it here rather than swapping assets
|
||||
// out from under an open note mid-edit.
|
||||
beforeMount(){
|
||||
this.$bus.$on('sw-update-available', this.onUpdateAvailable)
|
||||
},
|
||||
beforeUnmount(){
|
||||
this.$bus.$off('sw-update-available', this.onUpdateAvailable)
|
||||
},
|
||||
methods: {
|
||||
onUpdateAvailable(){
|
||||
this.visible = true
|
||||
},
|
||||
apply(){
|
||||
this.visible = false
|
||||
// Imported lazily: this component mounts with the app, but the
|
||||
// worker registers after mount.
|
||||
import('@/registerServiceWorker').then(({ updateSW }) => updateSW(true))
|
||||
},
|
||||
dismiss(){
|
||||
// Stays waiting; the hourly update check will offer it again.
|
||||
this.visible = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-prompt {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1em;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
padding: 0.75em 1em;
|
||||
border: 1px solid var(--border_color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--small_element_bg_color);
|
||||
color: var(--text_color);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
.update-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +1,51 @@
|
||||
// The Vue build version to load with the `import` command
|
||||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
import Vuex from 'vuex'
|
||||
import store from './stores/mainStore';
|
||||
|
||||
import App from './App'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './stores/mainStore'
|
||||
|
||||
//Include entire fomantic ui library
|
||||
// import 'fomantic-ui-css/semantic.css';
|
||||
|
||||
//Required site and reset CSS
|
||||
import 'fomantic-ui-css/components/reset.min.css'
|
||||
import 'fomantic-ui-css/components/site.css' //modified to remove included LATO fonts
|
||||
|
||||
//Only include parts that are used
|
||||
import 'fomantic-ui-css/components/site.css'
|
||||
import 'fomantic-ui-css/components/button.min.css'
|
||||
import 'fomantic-ui-css/components/container.min.css'
|
||||
import 'fomantic-ui-css/components/form.min.css'
|
||||
import 'fomantic-ui-css/components/grid.min.css'
|
||||
import 'fomantic-ui-css/components/header.min.css'
|
||||
import 'fomantic-ui-css/components/icon.css' //Modified to remove brand icons
|
||||
import 'fomantic-ui-css/components/icon.css'
|
||||
import 'fomantic-ui-css/components/input.min.css'
|
||||
import 'fomantic-ui-css/components/segment.min.css'
|
||||
import 'fomantic-ui-css/components/label.min.css'
|
||||
|
||||
import './assets/semantic-helper.css'
|
||||
|
||||
//Overwrite and site styles and themes and good stuff
|
||||
require('./assets/semantic-helper.css')
|
||||
// Fonts
|
||||
require('./assets/roboto-latin.woff2')
|
||||
require('./assets/roboto-latin-bold.woff2')
|
||||
// Last, so Tailwind utilities win specificity ties against Fomantic
|
||||
import './tailwind.css'
|
||||
|
||||
|
||||
|
||||
|
||||
//Import socket io, init using nginx configured socket path
|
||||
import io from 'socket.io-client';
|
||||
const socket = io({ path:'/socket' });
|
||||
|
||||
//integrate connected socket into vue instance
|
||||
Object.defineProperties(Vue.prototype, {
|
||||
$io: {
|
||||
get: () => socket
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// This callback runs before every route change, including on page load.
|
||||
// Sets the title of the page using vue router
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title + ' - Solid Scribe';
|
||||
next();
|
||||
});
|
||||
|
||||
//Attach event bus to main vue object, all components will inherit event bus
|
||||
import io from 'socket.io-client'
|
||||
import EventBus from './EventBus'
|
||||
import Helpers from './Helpers'
|
||||
import { installLaunchRedirect } from './LaunchPage'
|
||||
|
||||
Vue.use(Vuex)
|
||||
Vue.config.productionTip = false
|
||||
const socket = io({ path: '/socket' })
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title + ' - Solid Scribe'
|
||||
next()
|
||||
})
|
||||
|
||||
// Installed launches land on "/" and are redirected to the user's chosen
|
||||
// launch page -- see LaunchPage.js
|
||||
installLaunchRedirect(router)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.config.globalProperties.$io = socket
|
||||
app.config.globalProperties.$bus = EventBus
|
||||
|
||||
app.use(router)
|
||||
app.use(store)
|
||||
app.use(Helpers)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// After mount, so a failed registration can never block the app rendering
|
||||
import('./registerServiceWorker')
|
||||
@@ -171,7 +171,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
this.$router.go(-1)
|
||||
|
||||
setTimeout(()=>{
|
||||
@@ -189,7 +189,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -249,7 +249,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile
|
||||
this.$router.go(-1)
|
||||
@@ -317,7 +317,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -377,13 +377,13 @@ const SquireButtonFunctions = {
|
||||
// this.diffNoteText = this.editor._getHTML()
|
||||
|
||||
//Make sure all list items have draggable property
|
||||
let container = document.getElementById('squire-id')
|
||||
let listItems = container.getElementsByTagName('li')
|
||||
for(let itemIndex in listItems){
|
||||
// console.log(listItems[itemIndex])
|
||||
// listItems[itemIndex].setAttribute('draggable','true')
|
||||
}
|
||||
// console.log(listItems)
|
||||
// let container = this.$refs.squirebox
|
||||
// let listItems = container.getElementsByTagName('li')
|
||||
// for(let itemIndex in listItems){
|
||||
// // console.log(listItems[itemIndex])
|
||||
// // listItems[itemIndex].setAttribute('draggable','true')
|
||||
// }
|
||||
// // console.log(listItems)
|
||||
|
||||
},
|
||||
getText(){
|
||||
|
||||
@@ -102,13 +102,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'attachment-display': require('@/components/AttachmentDisplayCard').default,
|
||||
'counter':require('@/components/AnimatedCounterComponent.vue').default,
|
||||
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
||||
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -124,7 +125,7 @@
|
||||
//
|
||||
// Perform Login check
|
||||
//
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted: function(){
|
||||
@@ -140,7 +141,7 @@
|
||||
//Mount notes on load if note ID is set
|
||||
this.searchAttachments()
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
//Remove scroll event on destroy
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
},
|
||||
beforeCreate: function(){
|
||||
// Perform Login check
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted: function(){
|
||||
this.getBookmarklet()
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<style>
|
||||
.calendar {
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
display: inline-block;
|
||||
}
|
||||
.day {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border_color);
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.today {
|
||||
font-weight: bold;
|
||||
}
|
||||
.day ~ .active {
|
||||
|
||||
background-color: var(--main-accent);
|
||||
color: var(--body_bg_color);
|
||||
}
|
||||
.day ~ .has-data {
|
||||
outline: #07f4f4;
|
||||
outline-style: none;
|
||||
outline-width: medium;
|
||||
outline-style: none;
|
||||
outline-offset: -4px;
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="squire-box">
|
||||
|
||||
<h3>Cycle Tracking Beta
|
||||
<span v-on:click="deleteData()">
|
||||
<i class="clickable trash icon"></i>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div class="calendar">
|
||||
<p>{{calendar.monthName}}, {{calendar.year}}</p>
|
||||
<div v-for="day in calendar.weekdays" class="day">
|
||||
{{ day }}
|
||||
</div>
|
||||
<div v-for="day in calendar.days" class="day"
|
||||
:class="{
|
||||
'today':day == calendar.today,
|
||||
'active':dateCode == `${day}.${calendar.month}.${calendar.year}`,
|
||||
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
|
||||
}"
|
||||
v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui form">
|
||||
<h3><input type="text" v-model="dateCode" v-on:keypress.enter="openDayData(dateCode)"></h3>
|
||||
<div class="field" v-for="field in fields">
|
||||
<label>{{ field.label }}</label>
|
||||
|
||||
<div v-for="(value, key) in openDay">
|
||||
|
||||
<!-- float -->
|
||||
<div v-if="field.type == 'float' && field.id == key" class="ui left icon input">
|
||||
<input type="text" :placeholder="field.label" v-on:keyup="e => saveField(field.id, e.target.value)" :value="value">
|
||||
<i class="thermometer half icon"></i>
|
||||
</div>
|
||||
|
||||
<!-- range -->
|
||||
<div v-if="field.type == 'range' && field.id == key">
|
||||
<div :class="{green:(value == 1)}" v-on:click="saveField(field.id, 1)" class="ui button">1</div>
|
||||
<div :class="{green:(value == 2)}" v-on:click="saveField(field.id, 2)" class="ui button">2</div>
|
||||
<div :class="{green:(value == 3)}" v-on:click="saveField(field.id, 3)" class="ui button">3</div>
|
||||
<div :class="{green:(value == 4)}" v-on:click="saveField(field.id, 4)" class="ui button">4</div>
|
||||
<div :class="{green:(value == 5)}" v-on:click="saveField(field.id, 5)" class="ui button">5</div>
|
||||
</div>
|
||||
|
||||
<!-- text area -->
|
||||
<div v-if="field.type == 'text' && field.id == key">
|
||||
<textarea rows="3" v-on:keyup="e => saveField(field.id, e.target.value)">
|
||||
{{ value }}
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ui green save button" v-on:click="saveDayData">
|
||||
Save
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'CycleTracking',
|
||||
components: {
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
appWorkingDate: null,
|
||||
fields:[],
|
||||
defaultFields:[
|
||||
{'type':'float','label':'Basil Temp', 'id':'BT'},
|
||||
{'type':'range','label':'Cervical Mucus', 'id':'CM'},
|
||||
{'type':'text','label':'Notes', 'id':'NO'},
|
||||
],
|
||||
cycleData: {},
|
||||
dateCode: null,
|
||||
openDay: [],
|
||||
calendar: {
|
||||
monthName: '',
|
||||
month: '',
|
||||
year: '',
|
||||
days: [],
|
||||
weekdays: ['S','M','T','W','T','F','S'],
|
||||
today: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
|
||||
// Perform Login check
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted(){
|
||||
if(!this.$store.getters.getLoggedIn){
|
||||
return
|
||||
}
|
||||
|
||||
// setup date code
|
||||
// day - month - year
|
||||
const now = new Date()
|
||||
this.appWorkingDate = now
|
||||
const dateSetup = [
|
||||
now.getDate(), // 1-31 (Day)
|
||||
now.getMonth()+1, // 0-11 (Month)
|
||||
now.getFullYear(), // 1888-2022 (Year)
|
||||
]
|
||||
|
||||
this.dateCode = dateSetup.join('.')
|
||||
|
||||
this.setupCalendar(this.appWorkingDate)
|
||||
|
||||
this.fetchCycleData()
|
||||
},
|
||||
methods: {
|
||||
saveField(fieldId, value){
|
||||
this.openDay[fieldId] = value
|
||||
},
|
||||
openDayData(dateCode){
|
||||
|
||||
if(String(dateCode).split('.').length != 3){
|
||||
console.log('Invalid Date Code')
|
||||
return
|
||||
}
|
||||
|
||||
this.dateCode = dateCode || this.dateCode
|
||||
|
||||
let currentDay = this.cycleData[this.dateCode] || {}
|
||||
|
||||
//Set up each entry empty or with current value
|
||||
this.fields.forEach(field => {
|
||||
currentDay[field.id] = currentDay[field.id] || ''
|
||||
})
|
||||
|
||||
this.openDay = currentDay
|
||||
|
||||
},
|
||||
saveDayData(){
|
||||
|
||||
// remove empty keys
|
||||
let cleanDayData = {}
|
||||
Object.keys(this.openDay).forEach(key => {
|
||||
if(this.openDay[key] != ''){
|
||||
cleanDayData[key] = this.openDay[key]
|
||||
}
|
||||
})
|
||||
|
||||
this.cycleData[this.dateCode] = cleanDayData
|
||||
|
||||
// Update calendar
|
||||
this.setupCalendar(this.appWorkingDate)
|
||||
|
||||
this.saveCycleData()
|
||||
|
||||
},
|
||||
fetchCycleData(){
|
||||
|
||||
axios.post('/api/cycle-tracking/get')
|
||||
.then(({ data }) => {
|
||||
if(data.hasOwnProperty("text")){
|
||||
|
||||
let appData = null
|
||||
try {
|
||||
appData = JSON.parse(data.text)
|
||||
} catch(e) {
|
||||
console.log('Didnt parse json')
|
||||
}
|
||||
|
||||
console.log(appData)
|
||||
|
||||
this.cycleData = appData?.cycleData || {}
|
||||
this.fields = appData?.fields || []
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.setupFields()
|
||||
this.openDayData(this.dateCode)
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', error) })
|
||||
|
||||
},
|
||||
saveCycleData(){
|
||||
|
||||
const appData = JSON.stringify({
|
||||
fields: this.fields,
|
||||
cycleData: this.cycleData,
|
||||
})
|
||||
axios.post('/api/cycle-tracking/save', { cycleData:appData })
|
||||
.then(response => {
|
||||
{ this.$bus.$emit('notification', 'Data Saved') }
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', error) })
|
||||
},
|
||||
deleteData(){
|
||||
axios.post('/api/cycle-tracking/save', { cycleData:'' })
|
||||
.then(response => {
|
||||
{ this.$bus.$emit('notification', 'Data Deleted') }
|
||||
})
|
||||
},
|
||||
setupFields(){
|
||||
|
||||
// push the first 3 default fields to users set
|
||||
if(this.fields.length == 0){
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.fields.push(this.defaultFields[i])
|
||||
}
|
||||
}
|
||||
},
|
||||
setupCalendar(date){
|
||||
// ------------
|
||||
// setup calendar display
|
||||
var y = date.getFullYear()
|
||||
var m = date.getMonth()
|
||||
|
||||
var firstDay = new Date(y, m, 1);
|
||||
var lastDay = new Date(y, m + 1, 0);
|
||||
|
||||
function getDaysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
const currentYear = date.getFullYear();
|
||||
const currentMonth = date.getMonth() + 1;
|
||||
this.calendar.monthName = date.toLocaleString("en-US", { month: "long" });
|
||||
this.calendar.year = currentYear
|
||||
const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
|
||||
|
||||
const monthStartDay = firstDay.getDay()
|
||||
let days = Array(monthStartDay).fill("."); // Pad days to start on correct weekday
|
||||
for (let i = 0; i < daysInCurrentMonth; i++) {
|
||||
days.push(i+1)
|
||||
}
|
||||
this.calendar.days = days
|
||||
|
||||
// set today
|
||||
this.calendar.today = date.getDate()
|
||||
this.calendar.month = date.getMonth()+1
|
||||
|
||||
|
||||
/*
|
||||
October 2022
|
||||
S M T W T F S
|
||||
1 2 3 4 5 6
|
||||
7 8 9
|
||||
*/
|
||||
|
||||
// -------
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="center aligned sixteen wide column">
|
||||
<router-link to="/terms"></i>Solid Scribe Terms of Use</router-link>
|
||||
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Go to notes button -->
|
||||
<div class="row" v-if="$parent.loggedIn">
|
||||
<div class="row" v-if="$root.loggedIn">
|
||||
<div class="sixteen wide middle algined center aligned column">
|
||||
<h3>You are already logged in</h3>
|
||||
<router-link class="ui huge green labeled icon button" to="/notes">
|
||||
@@ -184,7 +184,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Small login form -->
|
||||
<div class="row" v-if="!$parent.loggedIn">
|
||||
<div class="row" v-if="!$root.loggedIn">
|
||||
<div class="sixteen wide middle algined column">
|
||||
<div class="ui text container">
|
||||
<h2>
|
||||
@@ -216,7 +216,7 @@
|
||||
<h2 style="color: var(--main-accent);">
|
||||
Pick your theme
|
||||
</h2>
|
||||
<h3 v-if="$parent.loggedIn">Go to settings to change theme</h3>
|
||||
<h3 v-if="$root.loggedIn">Go to settings to change theme</h3>
|
||||
<div
|
||||
v-for="color in themeColors"
|
||||
v-bind:key="color"
|
||||
@@ -532,12 +532,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
export default {
|
||||
name: 'WelcomePage',
|
||||
components: {
|
||||
'login-form':require('@/components/LoginFormComponent.vue').default,
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'svg-displayer':require('@/components/SvgDisplayer.vue').default,
|
||||
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
'svg-displayer':defineAsyncComponent(() => import('@/components/SvgDisplayer.vue')),
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
@@ -562,10 +563,10 @@ export default {
|
||||
},
|
||||
beforeCreate(){
|
||||
//Force HTTPS on prod, always. Dev doesn't have certs
|
||||
const isDev = process.env['NODE_ENV'] == 'development'
|
||||
if (!isDev && location.protocol != 'https:'){
|
||||
window.location.replace('https://www.solidscribe.com')
|
||||
}
|
||||
// const isDev = process.env['NODE_ENV'] == 'development'
|
||||
// if (!isDev && location.protocol != 'https:'){
|
||||
// window.location.replace('https://www.solidscribe.com')
|
||||
// }
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
components: {
|
||||
'login-form':require('@/components/LoginFormComponent.vue').default,
|
||||
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
</div>
|
||||
|
||||
<!-- render each section based on notes in set -->
|
||||
<div v-for="section,index in noteSections" v-if="section.length > 0" class="note-card-section">
|
||||
<template v-for="section,index in noteSections" :key="index">
|
||||
<div v-if="section.length > 0" class="note-card-section">
|
||||
<h5 class="ui tiny dividing header"><i :class="`green ${sectionData[index][0]} icon`"></i>{{ sectionData[index][1] }}</h5>
|
||||
|
||||
<div class="note-card-display-area">
|
||||
@@ -133,6 +134,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="loading-section" v-if="showLoading">
|
||||
@@ -163,20 +165,21 @@
|
||||
|
||||
<!-- flexbox note container evenly spaces open notes -->
|
||||
<div class="note-panel-container" :class="{ 'note-panel-fullwidth':collapseFloatingList}" v-if="openNotes.length">
|
||||
<note-input-panel
|
||||
v-for="noteId in openNotes"
|
||||
<template v-for="noteId in openNotes" :key="noteId">
|
||||
<note-input-panel
|
||||
v-if="noteId != null"
|
||||
:key="noteId"
|
||||
:noteid="noteId"
|
||||
:url-data="$route.params"
|
||||
:open-notes="openNotes.length"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -184,15 +187,15 @@
|
||||
name: 'NotesPage',
|
||||
components: {
|
||||
|
||||
'note-input-panel': () => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue'),
|
||||
'note-input-panel': defineAsyncComponent(() => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue')),
|
||||
|
||||
'note-title-display-card': require('@/components/NoteTitleDisplayCard.vue').default,
|
||||
// 'fast-filters': require('@/components/FastFilters.vue').default,
|
||||
'search-input': require('@/components/SearchInput.vue').default,
|
||||
'attachment-display': require('@/components/AttachmentDisplayCard').default,
|
||||
'tag-display':require('@/components/TagDisplayComponent.vue').default,
|
||||
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
|
||||
'paste-button':require('@/components/PasteButton.vue').default,
|
||||
'note-title-display-card': defineAsyncComponent(() => import('@/components/NoteTitleDisplayCard.vue')),
|
||||
// 'fast-filters': defineAsyncComponent(() => import('@/components/FastFilters.vue')),
|
||||
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
||||
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
||||
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
|
||||
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
'paste-button':defineAsyncComponent(() => import('@/components/PasteButton.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -203,6 +206,9 @@
|
||||
searchTags: [],
|
||||
notes: [],
|
||||
openNotes: [],
|
||||
// fullPath of our own last URL write, so the $route watcher can
|
||||
// recognise it and not treat it as an inbound navigation
|
||||
lastSyncedFullPath: null,
|
||||
collapseFloatingList: false,
|
||||
highlights: [],
|
||||
searchDebounce: null,
|
||||
@@ -258,7 +264,7 @@
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
//If user is on title view,
|
||||
this.titleView = this.$store.getters.getIsUserOnMobile
|
||||
@@ -363,7 +369,7 @@
|
||||
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
|
||||
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
|
||||
|
||||
@@ -380,11 +386,26 @@
|
||||
},
|
||||
mounted() {
|
||||
|
||||
//Open note on PAGE LOAD if ID is set
|
||||
if(this.$route.params.id > 1){
|
||||
this.openNote(this.$route.params.id)
|
||||
// Restore every note named in ?open=
|
||||
const ids = this.parseOpenQuery(this.$route.query.open)
|
||||
|
||||
// Fold the legacy /notes/open/:id deep link in, first position
|
||||
const paramId = parseInt(this.$route.params.id, 10)
|
||||
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
|
||||
ids.unshift(paramId)
|
||||
}
|
||||
|
||||
// Push directly rather than via openNote(), which would write the URL
|
||||
// once per note; one normalising write follows instead.
|
||||
ids.forEach(id => {
|
||||
if(!this.openNotes.includes(id)){
|
||||
this.openNotes.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Unconditional, so a junk ?open=abc gets scrubbed too
|
||||
this.syncOpenNotesToUrl()
|
||||
|
||||
//Loads initial batch and tags
|
||||
this.reset()
|
||||
|
||||
@@ -395,17 +416,31 @@
|
||||
},
|
||||
'$route' (to, from) {
|
||||
|
||||
// Our own URL write, not an inbound navigation
|
||||
if(to.fullPath === this.lastSyncedFullPath){ return }
|
||||
|
||||
// Reload the notes if returning to this page
|
||||
if(to.fullPath == '/notes' && !from.fullPath.includes('/notes/open/')){
|
||||
// The discriminator is the absence of ?open=, NOT the path. A menu's
|
||||
// $router.go(-1) lands on /notes?open=5 with `from` containing
|
||||
// '/notes/open/', so a path comparison here would close every note
|
||||
// every time a side menu is dismissed.
|
||||
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
|
||||
|
||||
// Reload the notes if arriving at an empty /notes from elsewhere
|
||||
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
|
||||
this.reset()
|
||||
}
|
||||
|
||||
// Close all notes if returning to /notes page
|
||||
if(to.fullPath == '/notes' && from.fullPath.includes('/notes/open/')){
|
||||
// Explicit navigation to a note-less /notes closes everything
|
||||
if(landingOnBareNotes && this.openNotes.length > 0){
|
||||
this.closeAllNotes()
|
||||
}
|
||||
|
||||
// Stale history entry (e.g. go(-1) after another note was closed):
|
||||
// rewrite the URL from the array. Never read it.
|
||||
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
//Lookup tags set in URL
|
||||
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
||||
|
||||
@@ -432,81 +467,121 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openNote(id, event = null){
|
||||
// Where the open-note URL should point. A menu or a tag search owns the
|
||||
// path, so only a plain notes view gets normalised back to /notes.
|
||||
openNotesTargetPath(){
|
||||
|
||||
//
|
||||
// A side menu is open; its close button is a $router.go(-1), so the
|
||||
// path has to stay put or there is nowhere sane to land.
|
||||
if(this.$route.params.openMenu){ return this.$route.path }
|
||||
|
||||
const intId = parseInt(id)
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Preserve a tag search
|
||||
if(this.$route.params.tag){ return this.$route.path }
|
||||
|
||||
console.log('Open already open note?')
|
||||
return '/notes'
|
||||
},
|
||||
// Mirror openNotes into ?open=. This is the ONLY place the open-note
|
||||
// URL is written.
|
||||
syncOpenNotesToUrl(){
|
||||
|
||||
// const openIndex = this.openNotes.indexOf(intId)
|
||||
// if(openIndex != -1){
|
||||
// console.log('Open note and remove it ', intId + ' on index ' + openIndex)
|
||||
// this.openNotes.splice(openIndex, 1)
|
||||
// }
|
||||
// this.$bus.$emit('close_note_by_id', intId)
|
||||
const query = Object.assign({}, this.$route.query)
|
||||
|
||||
if(this.openNotes.length > 0){
|
||||
query.open = this.openNotes.join(',')
|
||||
}
|
||||
else {
|
||||
delete query.open
|
||||
}
|
||||
|
||||
const target = { path: this.openNotesTargetPath(), query }
|
||||
const targetFullPath = this.$router.resolve(target).fullPath
|
||||
|
||||
// Already correct -- don't navigate at all
|
||||
if(targetFullPath === this.$route.fullPath){
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
return
|
||||
}
|
||||
|
||||
// Set before navigating so the watcher sees it
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
|
||||
this.$router.replace(target).catch(() => {})
|
||||
},
|
||||
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
|
||||
// repeated form ?open=1&open=2 (which arrives as an array).
|
||||
parseOpenQuery(raw){
|
||||
|
||||
if(raw === undefined || raw === null){ return [] }
|
||||
|
||||
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
|
||||
const MAX_RESTORE = 8
|
||||
|
||||
return [].concat(raw)
|
||||
.join(',')
|
||||
.split(',')
|
||||
.map(value => parseInt(value, 10))
|
||||
.filter(value => Number.isInteger(value) && value > 0)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.slice(0, MAX_RESTORE)
|
||||
},
|
||||
openNote(id, event = null){
|
||||
|
||||
const intId = parseInt(id, 10)
|
||||
|
||||
// Also absorbs the undefined the '$route.params.id' watcher now gets
|
||||
// when we normalise /notes/open/5 into /notes?open=5
|
||||
if(!Number.isInteger(intId) || intId < 1){ return }
|
||||
|
||||
//Don't open note if a link is clicked in display card
|
||||
if(event && event.target && event.target.nodeName){
|
||||
const nodeClick = event.target.nodeName
|
||||
if(nodeClick == 'A'){ return }
|
||||
}
|
||||
|
||||
// Push note to stack if not open
|
||||
if(Number.isInteger(intId) && !this.openNotes.includes(intId)){
|
||||
this.openNotes.push(intId)
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Already open, but an external push('/notes/open/5') still needs
|
||||
// the URL normalised back to the full set
|
||||
this.syncOpenNotesToUrl()
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
// change route if open ID is not the same as current ID
|
||||
if(this.$route.params.id != id){
|
||||
console.log('Open note, change route -> route id ' + this.$route.params.id + ' note id ->' + id + ', ' +(this.$route.params.id == id))
|
||||
this.$router.push('/notes/open/'+id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
return
|
||||
this.openNotes.push(intId)
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
closeNote(noteId, modified){
|
||||
closeNote(noteId, modified, syncUrl = true){
|
||||
|
||||
console.log('close note', this.$route.fullPath)
|
||||
// parseInt: indexOf is strict, and ids arriving from the URL are
|
||||
// strings. Without this the splice silently misses.
|
||||
const intId = parseInt(noteId, 10)
|
||||
const openIndex = this.openNotes.indexOf(intId)
|
||||
const didClose = (openIndex !== -1)
|
||||
|
||||
const openIndex = this.openNotes.indexOf(noteId)
|
||||
if(openIndex != -1){
|
||||
console.log('Removing note id ', noteId + ' on index ' + openIndex)
|
||||
if(didClose){
|
||||
this.openNotes.splice(openIndex, 1)
|
||||
}
|
||||
|
||||
// //A note has been closed
|
||||
// if(this.$route.fullPath != '/notes'){
|
||||
// this.$router.push('/notes')
|
||||
// }
|
||||
if(this.openNotes.length == 0 && this.$route.fullPath != '/notes'){
|
||||
this.$router.push('/notes')
|
||||
// Only write the URL when the array actually changed. NoteInputPanel
|
||||
// emits close_active_note twice per note (closeButtonAction -> close(),
|
||||
// then beforeUnmount -> close()); the second arrives after the splice.
|
||||
if(didClose && syncUrl){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
if(modified){
|
||||
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
|
||||
this.$store.dispatch('fetchAndUpdateUserTotals')
|
||||
//Focus and animate if modified
|
||||
this.updateSingleNote(noteId, modified)
|
||||
}
|
||||
|
||||
console.log('closeNote(): Open notes length ', this.openNotes.length)
|
||||
},
|
||||
closeAllNotes(){
|
||||
console.log('Close all notes ------------')
|
||||
|
||||
// Backwards, so the in-loop splice can't skip an entry. URL writes are
|
||||
// deferred so this is one navigation rather than N.
|
||||
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
||||
console.log('Close all notes -> ' + this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i], false, false)
|
||||
}
|
||||
console.log('----------------')
|
||||
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
toggleTagFilter(tagId){
|
||||
|
||||
@@ -741,7 +816,10 @@
|
||||
|
||||
return resolve(true)
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Notes') })
|
||||
.catch(error => {
|
||||
console.error('[NotesPage] search failed:', error)
|
||||
this.$bus.$emit('notification', 'Failed to Search Notes')
|
||||
})
|
||||
})
|
||||
},
|
||||
rebuildNoteCategorise(){
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
//
|
||||
// Perform Login check
|
||||
//
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
|
||||
@@ -44,6 +44,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App / PWA -->
|
||||
<h4 class="ui header">
|
||||
App
|
||||
</h4>
|
||||
<div class="ui segment">
|
||||
<div class="ui doubling grid">
|
||||
<div class="sixteen wide column">
|
||||
|
||||
<p>
|
||||
Which page the installed app opens to. Set automatically to
|
||||
whichever page you installed from, and changeable here.
|
||||
</p>
|
||||
<div
|
||||
v-for="option in launchOptions"
|
||||
:key="option.path"
|
||||
class="ui compact basic button"
|
||||
:class="{'green':(launchPage == option.path)}"
|
||||
v-on:click="chooseLaunchPage(option.path)">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<p>
|
||||
If the app gets stuck on an old version, this clears the stored
|
||||
copies of its files and reloads. Your notes are not affected.
|
||||
</p>
|
||||
<div class="ui compact basic button" v-on:click="clearAppCache()">
|
||||
<i class="redo icon"></i>
|
||||
Reset App Cache
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable Two Factor -->
|
||||
<h4>Two Factor Authentication</h4>
|
||||
<div class="ui segment">
|
||||
@@ -152,7 +188,7 @@
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="center aligned sixteen wide column">
|
||||
<router-link to="/terms"></i>Solid Scribe Terms of Use</router-link>
|
||||
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,13 +197,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import { LAUNCH_OPTIONS, getLaunchPage, setLaunchPage } from '@/LaunchPage'
|
||||
import { resetAppCache } from '@/registerServiceWorker'
|
||||
|
||||
export default {
|
||||
name: 'SettingsPage',
|
||||
components: {
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -176,6 +216,8 @@
|
||||
verificationToken: '',
|
||||
showNewNoteConfirm: false,
|
||||
|
||||
launchOptions: LAUNCH_OPTIONS,
|
||||
launchPage: getLaunchPage(),
|
||||
themeColors: [
|
||||
'#21BA45', //Green
|
||||
'#b5cc18', //Lime
|
||||
@@ -197,6 +239,14 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
chooseLaunchPage(path){
|
||||
setLaunchPage(path)
|
||||
this.launchPage = getLaunchPage()
|
||||
this.$bus.$emit('notification', 'App will open to ' + path)
|
||||
},
|
||||
clearAppCache(){
|
||||
resetAppCache()
|
||||
},
|
||||
newQuickNote(){
|
||||
|
||||
this.showNewNoteConfirm = true
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="ui container share-target">
|
||||
|
||||
<div class="ui basic center aligned segment">
|
||||
|
||||
<div v-if="status === 'working'">
|
||||
<loading-icon message="Saving to Scratch Pad" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'error'">
|
||||
<h3 class="ui header">
|
||||
<i class="exclamation triangle icon"></i>
|
||||
Couldn't save that
|
||||
</h3>
|
||||
<p>You may be offline. Your text is safe below — retry when you have a connection.</p>
|
||||
|
||||
<textarea class="shared-text" rows="8" readonly :value="pushText"></textarea>
|
||||
|
||||
<div class="ui buttons">
|
||||
<div class="ui green button" v-on:click="save()">
|
||||
<i class="redo icon"></i> Retry
|
||||
</div>
|
||||
<div class="ui basic button" v-on:click="discard()">
|
||||
Discard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<loading-icon message="Working" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
// Shared text is parked here when the share arrives while logged out. The
|
||||
// share's query string does not survive the bounce through /login, so a
|
||||
// redirect param alone would lose it.
|
||||
const STASH_KEY = 'pendingShareText'
|
||||
|
||||
// Guard against a pathological share wedging localStorage
|
||||
const MAX_STASH = 100 * 1024
|
||||
|
||||
export default {
|
||||
name: 'ShareTargetPage',
|
||||
components: {
|
||||
'loading-icon': defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
status: 'working',
|
||||
pushText: '',
|
||||
}
|
||||
},
|
||||
// Deliberately NOT calling $root.loginGateway() the way other pages do --
|
||||
// it would bounce away and discard the shared payload before we read it.
|
||||
mounted(){
|
||||
|
||||
const text = this.composeSharedText()
|
||||
|
||||
if(!text){
|
||||
this.$router.replace('/quick')
|
||||
return
|
||||
}
|
||||
|
||||
this.pushText = text
|
||||
|
||||
// getLoggedIn is reliable here: App.vue's beforeCreate populates the
|
||||
// username from localStorage before any route component mounts.
|
||||
if(!this.$store.getters.getLoggedIn){
|
||||
this.stash(text)
|
||||
this.$router.replace({ path: '/login', query: { redirect: '/share-target' } })
|
||||
return
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
methods: {
|
||||
// Android routinely duplicates content across these fields -- the URL
|
||||
// lands in both `text` and `url`, the page title in both `title` and
|
||||
// `text`. Without the dedupe every shared link is saved twice.
|
||||
composeSharedText(){
|
||||
|
||||
const query = this.$route.query
|
||||
|
||||
const parts = ['title', 'text', 'url']
|
||||
.map(key => String(query[key] ?? '').trim())
|
||||
.filter(value => value.length > 0)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
|
||||
if(parts.length > 0){ return parts.join('\n') }
|
||||
|
||||
// Nothing in the URL: this is the replay after logging in
|
||||
return localStorage.getItem(STASH_KEY) || ''
|
||||
},
|
||||
stash(text){
|
||||
if(text.length > MAX_STASH){ return }
|
||||
localStorage.setItem(STASH_KEY, text)
|
||||
},
|
||||
save(){
|
||||
|
||||
this.status = 'working'
|
||||
|
||||
// Same call the paste button makes. The server prepends to the
|
||||
// scratch pad, strips HTML and auto-links URLs, so nothing needs
|
||||
// formatting here.
|
||||
axios.post('/api/quick-note/update', { 'pushText': this.pushText })
|
||||
.then(() => {
|
||||
localStorage.removeItem(STASH_KEY)
|
||||
this.$bus.$emit('notification', 'Saved To Scratch Pad')
|
||||
this.$router.replace('/quick')
|
||||
})
|
||||
.catch(error => {
|
||||
// Keep the stash: offline is the likely cause and the service
|
||||
// worker means the app boots without a connection.
|
||||
console.error('[ShareTarget] save failed:', error)
|
||||
this.stash(this.pushText)
|
||||
this.status = 'error'
|
||||
})
|
||||
},
|
||||
discard(){
|
||||
localStorage.removeItem(STASH_KEY)
|
||||
this.$router.replace('/quick')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-target {
|
||||
padding-top: 3em;
|
||||
}
|
||||
.shared-text {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
padding: 0.75em;
|
||||
border: 1px solid var(--border_color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--small_element_bg_color);
|
||||
color: var(--text_color);
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import EventBus from './EventBus'
|
||||
|
||||
// How often to ask the server whether a new build exists
|
||||
const UPDATE_INTERVAL = 60 * 60 * 1000
|
||||
|
||||
//
|
||||
// The worker precaches static build output only -- see the SECURITY INVARIANT
|
||||
// comment in vite.config.js. Nothing user-derived ever enters Cache Storage,
|
||||
// which is why caches are deliberately NOT cleared on logout: doing so would
|
||||
// only destroy the offline shell.
|
||||
//
|
||||
export const updateSW = registerSW({
|
||||
onNeedRefresh(){
|
||||
// A new build is waiting. Let the user apply it rather than swapping
|
||||
// assets underneath them mid-edit.
|
||||
EventBus.$emit('sw-update-available')
|
||||
},
|
||||
onOfflineReady(){
|
||||
EventBus.$emit('notification', 'Ready to work offline')
|
||||
},
|
||||
onRegisteredSW(swUrl, registration){
|
||||
if(!registration){ return }
|
||||
setInterval(() => registration.update(), UPDATE_INTERVAL)
|
||||
},
|
||||
onRegisterError(error){
|
||||
console.error('[SW] registration failed:', error)
|
||||
},
|
||||
})
|
||||
|
||||
//
|
||||
// Escape hatch. A wedged service worker is very hard to talk someone through
|
||||
// remotely, so this is surfaced as a button in Settings.
|
||||
//
|
||||
export async function resetAppCache(){
|
||||
|
||||
if('serviceWorker' in navigator){
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
}
|
||||
|
||||
if('caches' in window){
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map(key => caches.delete(key)))
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
@@ -1,133 +1,41 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
//Breaking components into function sections allows webpack to load them dynamically
|
||||
const HomePage = () => import('@/pages/HomePage.vue')
|
||||
const LoginPage = () => import('@/pages/LoginPage.vue')
|
||||
const HelpPage = () => import('@/pages/HelpPage.vue')
|
||||
const TermsPage = () => import('@/pages/TermsPage.vue')
|
||||
const SettingsPage = () => import('@/pages/SettingsPage.vue')
|
||||
const SharePage = () => import('@/pages/SharePage.vue')
|
||||
const NotesPage = () => import('@/pages/NotesPage.vue')
|
||||
const QuickPage = () => import('@/pages/QuickPage.vue')
|
||||
const AttachmentsPage = () => import('@/pages/AttachmentsPage.vue')
|
||||
const OverviewPage = () => import('@/pages/OverviewPage.vue')
|
||||
const BookmarkletPage = () => import('@/pages/BookmarkletPage.vue')
|
||||
const NotFoundPage = () => import('@/pages/NotFoundPage.vue')
|
||||
const MetrictrackingPage = () => import('@/pages/MetrictrackingPage.vue')
|
||||
const ShareTargetPage = () => import('@/pages/ShareTargetPage.vue')
|
||||
|
||||
const HomePage = () => import(/* webpackChunkName: "HomePage" */ '@/pages/HomePage')
|
||||
const LoginPage = () => import(/* webpackChunkName: "LoginPage" */ '@/pages/LoginPage')
|
||||
const HelpPage = () => import(/* webpackChunkName: "HelpPage" */ '@/pages/HelpPage')
|
||||
const TermsPage = () => import(/* webpackChunkName: "TermsPage" */ '@/pages/TermsPage')
|
||||
const SettingsPage = () => import(/* webpackChunkName: "SettingsPage" */ '@/pages/SettingsPage')
|
||||
const SharePage = () => import(/* webpackChunkName: "SharePage" */ '@/pages/SharePage')
|
||||
const NotesPage = () => import(/* webpackChunkName: "NotesPage" */ '@/pages/NotesPage')
|
||||
const QuickPage = () => import(/* webpackChunkName: "QuickPage" */ '@/pages/QuickPage')
|
||||
const AttachmentsPage = () => import(/* webpackChunkName: "AttachmentsPage" */ '@/pages/AttachmentsPage')
|
||||
const OverviewPage = () => import(/* webpackChunkName: "OverviewPage" */ '@/pages/OverviewPage')
|
||||
const BookmarkletPage = () => import(/* webpackChunkName: "BookmarkletPage" */ '@/pages/BookmarkletPage')
|
||||
const NotFoundPage = () => import(/* webpackChunkName: "404Page" */ '@/pages/NotFoundPage')
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'HomePage',
|
||||
meta: {title:'Home'},
|
||||
component: HomePage
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'LoginPage',
|
||||
meta: {title:'Login'},
|
||||
component: LoginPage
|
||||
},
|
||||
{
|
||||
path: '/notes',
|
||||
name: 'Note Page', //don't change this
|
||||
meta: {title:'Notes'},
|
||||
component: NotesPage
|
||||
},
|
||||
{
|
||||
path: '/notes/open/:id',
|
||||
name: 'Open Note',
|
||||
meta: {title: 'Open Note'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/search/tags/:tag',
|
||||
name: 'Search Notes',
|
||||
meta: {title: 'Search Notes'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/notes/open/:id/menu/:openMenu',
|
||||
name: 'Open Note Menu',
|
||||
meta: {title: 'Open Note Menu'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/help',
|
||||
name: 'Help',
|
||||
meta: {title:'Help'},
|
||||
component: HelpPage
|
||||
},
|
||||
{
|
||||
path: '/terms',
|
||||
name: 'Terms',
|
||||
meta: {title:'Terms'},
|
||||
component: TermsPage
|
||||
},
|
||||
{
|
||||
path: '/bookmarklet',
|
||||
name: 'Bookmarklet',
|
||||
meta: {title:'Bookmarklet'},
|
||||
component: BookmarkletPage
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
meta: {title:'Settings'},
|
||||
component: SettingsPage
|
||||
},
|
||||
{
|
||||
path: '/public/note/:id/:token',
|
||||
name: 'Share',
|
||||
meta: {title:'Shared'},
|
||||
component: SharePage
|
||||
},
|
||||
{
|
||||
path: '/quick',
|
||||
name: 'Quick',
|
||||
meta: {title:'Scratch Pad'},
|
||||
component: QuickPage
|
||||
},
|
||||
{
|
||||
path: '/attachments',
|
||||
name: 'Attachments',
|
||||
meta: {title:'Attachments'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/attachments/note/:id',
|
||||
name: 'Attachments for Note',
|
||||
meta: {title:'Attachments for Note'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/attachments/type/:type',
|
||||
name: 'Attachments by Type',
|
||||
meta: {title:'Attachments by Type'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/overview',
|
||||
name: 'Overview of Notes',
|
||||
meta: {title:'Overview of Notes'},
|
||||
component: OverviewPage
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
name: 'Page Not Found',
|
||||
meta: {title:'404 Page Not Found'},
|
||||
component: NotFoundPage
|
||||
},
|
||||
// Cycle Tracking
|
||||
{
|
||||
path: '/metrictrack',
|
||||
name: 'Metric Tracking',
|
||||
meta: {title:'Metric Tracking'},
|
||||
component: () => import(/* webpackChunkName: "MetrictrackingPage" */ '@/pages/MetrictrackingPage')
|
||||
},
|
||||
]
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'HomePage', meta: { title: 'Home' }, component: HomePage },
|
||||
{ path: '/login', name: 'LoginPage', meta: { title: 'Login' }, component: LoginPage },
|
||||
{ path: '/notes', name: 'Note Page', meta: { title: 'Notes' }, component: NotesPage },
|
||||
{ path: '/notes/open/:id', name: 'Open Note', meta: { title: 'Open Note' }, component: NotesPage },
|
||||
{ path: '/search/tags/:tag', name: 'Search Notes', meta: { title: 'Search Notes' }, component: NotesPage },
|
||||
{ path: '/notes/open/:id/menu/:openMenu', name: 'Open Note Menu', meta: { title: 'Open Note Menu' }, component: NotesPage },
|
||||
{ path: '/help', name: 'Help', meta: { title: 'Help' }, component: HelpPage },
|
||||
{ path: '/terms', name: 'Terms', meta: { title: 'Terms' }, component: TermsPage },
|
||||
{ path: '/bookmarklet', name: 'Bookmarklet', meta: { title: 'Bookmarklet' }, component: BookmarkletPage },
|
||||
{ path: '/settings', name: 'Settings', meta: { title: 'Settings' }, component: SettingsPage },
|
||||
{ path: '/public/note/:id/:token', name: 'Share', meta: { title: 'Shared' }, component: SharePage },
|
||||
{ path: '/share-target', name: 'Share Target', meta: { title: 'Saving Share' }, component: ShareTargetPage },
|
||||
{ path: '/quick', name: 'Quick', meta: { title: 'Scratch Pad' }, component: QuickPage },
|
||||
{ path: '/attachments', name: 'Attachments', meta: { title: 'Attachments' }, component: AttachmentsPage },
|
||||
{ path: '/attachments/note/:id', name: 'Attachments for Note', meta: { title: 'Attachments for Note' }, component: AttachmentsPage },
|
||||
{ path: '/attachments/type/:type', name: 'Attachments by Type', meta: { title: 'Attachments by Type' }, component: AttachmentsPage },
|
||||
{ path: '/overview', name: 'Overview of Notes', meta: { title: 'Overview of Notes' }, component: OverviewPage },
|
||||
{ path: '/metrictrack', name: 'Metric Tracking', meta: { title: 'Metric Tracking' }, component: MetrictrackingPage },
|
||||
{ path: '/:pathMatch(.*)*', name: 'Page Not Found', meta: { title: '404 Page Not Found' }, component: NotFoundPage },
|
||||
],
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
},
|
||||
mutations: {
|
||||
},
|
||||
actions: {
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { createStore } from 'vuex'
|
||||
import axios from 'axios'
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
export default createStore({
|
||||
state: {
|
||||
username: null,
|
||||
nightMode: false,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Tailwind 4, wired to coexist with Fomantic UI.
|
||||
*
|
||||
* Deliberately NOT `@import "tailwindcss"` (the whole thing). That pulls in
|
||||
* Preflight as `@layer base`, which zeroes margin/padding/border on every
|
||||
* element, strips heading sizes and list markers, forces img/svg to
|
||||
* display:block and makes buttons and inputs transparent. Dropped on top of
|
||||
* Fomantic it visibly breaks the existing UI.
|
||||
*
|
||||
* Utilities are imported UNLAYERED on purpose. Tailwind's own index.css wraps
|
||||
* them in `@layer utilities`, and an unlayered stylesheet beats any layered
|
||||
* rule regardless of specificity -- since all of Fomantic is unlayered, the
|
||||
* utilities would quietly lose every conflict. Unlayered they compete on normal
|
||||
* specificity instead, and win ties by source order because this file is
|
||||
* imported last in main.js.
|
||||
*
|
||||
* Fomantic mostly uses two-class selectors (.ui.button = specificity 0,2,0), so
|
||||
* it still out-specifies a single utility (0,1,0). To beat Fomantic on a
|
||||
* property it already sets, use the important modifier -- `p-4!`, `bg-red-500!`.
|
||||
*
|
||||
* To try Preflight anyway, add: @import "tailwindcss/preflight.css" layer(base);
|
||||
*/
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css";
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// Opt-in HTTPS for the dev server, off by default so the usual
|
||||
// http://localhost:5173 workflow is unchanged. Only needed if you want to test
|
||||
// the installed PWA from a phone over the LAN *without* using
|
||||
// chrome://flags/#unsafely-treat-insecure-origin-as-secure -- and note Chrome
|
||||
// needs the mkcert root CA installed on the device for the origin to be
|
||||
// service-worker eligible; clicking through a cert warning is not enough.
|
||||
// DEV_HTTPS=1 pnpm dev
|
||||
const certDir = fileURLToPath(new URL('./certs', import.meta.url))
|
||||
const keyPath = `${certDir}/192.168.1.164+4-key.pem`
|
||||
const certPath = `${certDir}/192.168.1.164+4.pem`
|
||||
const useHttps =
|
||||
process.env.DEV_HTTPS === '1' && fs.existsSync(keyPath) && fs.existsSync(certPath)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
transformAssetUrls: { includeAbsolute: false },
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
// Updates are user-driven via the prompt in src/registerServiceWorker.js
|
||||
registerType: 'prompt',
|
||||
injectRegister: false,
|
||||
// Lands at dist/sw.js -- root of the served directory, so the worker gets
|
||||
// root scope without needing a Service-Worker-Allowed header.
|
||||
filename: 'sw.js',
|
||||
// We ship public/manifest.json by hand rather than generating one.
|
||||
manifest: false,
|
||||
workbox: {
|
||||
//
|
||||
// SECURITY INVARIANT -- do not relax without thinking it through.
|
||||
//
|
||||
// Solid Scribe is end-to-end encrypted. The service worker precaches
|
||||
// ONLY static build output and is network-only for /api/*. Caching an
|
||||
// API response would write decrypted note content into Cache Storage on
|
||||
// disk, where it survives logout and sits outside the encryption model.
|
||||
//
|
||||
// Structurally this holds because globPatterns only ever reads dist/,
|
||||
// and user content is served by Express from staticFiles/ at
|
||||
// /api/static -- which never enters dist. The NetworkOnly rule below is
|
||||
// belt-and-braces: it documents the invariant and stops a future
|
||||
// runtimeCaching entry from silently widening the net.
|
||||
//
|
||||
// woff2 is NOT in Workbox's default globPatterns. Without it the Roboto
|
||||
// files are skipped and the offline shell renders in a fallback face.
|
||||
globPatterns: ['**/*.{js,css,html,woff2,png,svg,ico,json}'],
|
||||
// Vendor chunks (fomantic, chart.js) exceed the 2MiB default. An
|
||||
// oversized file is silently OMITTED from the precache, which half
|
||||
// breaks the offline shell -- watch the build output for a warning.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
navigateFallback: '/index.html',
|
||||
// Matters for top-level navigations to /api/static/<file> (clicking an
|
||||
// attachment link) -- without this the worker answers with index.html.
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/socket/],
|
||||
cleanupOutdatedCaches: true,
|
||||
// Let the user decide when to swap assets, rather than doing it
|
||||
// mid-session underneath them.
|
||||
clientsClaim: false,
|
||||
skipWaiting: false,
|
||||
//
|
||||
// runtimeCaching is deliberately EMPTY. Do not add an entry for /api/*
|
||||
// or /socket -- not even NetworkOnly.
|
||||
//
|
||||
// There used to be a NetworkOnly route for both, as belt-and-braces for
|
||||
// the invariant above. It was worse than useless. registerRoute() makes
|
||||
// the worker call event.respondWith() for every matching request, and
|
||||
// Socket.IO's long-poll requests are aborted as a matter of course --
|
||||
// on reconnect, on transport upgrade, and whenever Android backgrounds
|
||||
// the page. An aborted fetch inside NetworkOnly throws Workbox's
|
||||
// `no-response`, the respondWith promise rejects, and Socket.IO sees a
|
||||
// dead transport and reconnects. The installed PWA was dropping its
|
||||
// live note sync on a loop and filling the logs with
|
||||
// "FetchEvent ... resulted in a network error response".
|
||||
//
|
||||
// With no route registered the worker never intercepts these at all and
|
||||
// the browser handles them natively, exactly as in a normal tab.
|
||||
//
|
||||
// Nothing is lost on the security side: the invariant is enforced by
|
||||
// globPatterns only ever reading dist/, and by there being no *caching*
|
||||
// strategy here. NetworkOnly enforced nothing -- it only inserted the
|
||||
// worker into the hot path of every poll.
|
||||
//
|
||||
},
|
||||
// Flip to true temporarily to exercise the worker on the dev server.
|
||||
// Remember to unregister it afterwards -- a lingering dev service worker
|
||||
// is a classic "why is my phone showing an ancient build".
|
||||
devOptions: { enabled: false, type: 'module' },
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
...(useHttps
|
||||
? { https: { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) } }
|
||||
: {}),
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/socket': { target: 'http://localhost:3001', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
#
|
||||
# Working dev server config
|
||||
#
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name 192.168.1.164;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
|
||||
listen 443 ssl;
|
||||
|
||||
ssl_certificate /home/mab/ss/client/certs/nginx-selfsigned.crt;
|
||||
ssl_certificate_key /home/mab/ss/client/certs/nginx-selfsigned.key;
|
||||
ssl_dhparam /home/mab/ss/client/certs/dhparam.pem;
|
||||
|
||||
ssl_session_cache shared:SSL:1m;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSV1.1 TLSV1.2 TLSV1.3;
|
||||
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
access_log /var/log/nginx/httpslocalhost.access.log;
|
||||
error_log /var/log/nginx/httpslocalhost.error.log;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:8081;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Forwarded-Host localhost;
|
||||
proxy_set_header X-Forwarded-Server localhost;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_redirect off;
|
||||
proxy_connect_timeout 90s;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_send_timeout 90s;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /sockjs-node {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_pass https://127.0.0.1:8081;
|
||||
proxy_redirect off;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_redirect off;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /socket {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Prod settings to serve static index
|
||||
# location / {
|
||||
# autoindex on;
|
||||
# #try_files $uri $uri/ /index.html;
|
||||
# }
|
||||
|
||||
# location / {
|
||||
# #autoindex on
|
||||
#
|
||||
# proxy_pass http://127.0.0.1:8444;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection 'upgrade';
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_cache_bypass $http_upgrade;
|
||||
# }
|
||||
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# nginx for Solid Scribe on NixOS.
|
||||
#
|
||||
# Merge this into your configuration.nix and `nixos-rebuild switch`.
|
||||
# Replace REPO_PATH with the absolute path of the repo clone on the server.
|
||||
#
|
||||
# Four things differ from the previous config, and the first two are required
|
||||
# for the Vue 3 client to work at all:
|
||||
#
|
||||
# 1. SPA fallback. The old Vue 2 client used vue-router's default HASH mode
|
||||
# (no `mode:` was set), so every request was just "/" and routing happened
|
||||
# after the "#". That is why share links looked like /#/public/note/...
|
||||
# The Vue 3 client uses createWebHistory, so /notes, /settings and
|
||||
# /share-target are real HTTP requests. Without tryFiles they all 404.
|
||||
#
|
||||
# 2. root moves up to the vhost. It was inside locations."/", which left
|
||||
# /assets/, /sw.js and /manifest.json unresolvable once they needed their
|
||||
# own location blocks.
|
||||
#
|
||||
# It also no longer points into the repo. deploy.sh publishes the built
|
||||
# client to /var/www/ss/releases/<timestamp> and atomically repoints the
|
||||
# /var/www/ss/current symlink at it. Pointing nginx at that stable path
|
||||
# means a deploy is one rename -- nginx resolves the symlink per request,
|
||||
# so no reload is needed and no request sees a half-copied directory.
|
||||
# Rollback is the same rename pointing at an older release.
|
||||
#
|
||||
# 3. proxyWebsockets on /socket. recommendedProxySettings does NOT add the
|
||||
# Upgrade/Connection headers, so Socket.IO has been silently falling back
|
||||
# to long-polling -- that's the live note-sync path running degraded.
|
||||
#
|
||||
# 4. Cache headers. See the warning below.
|
||||
#
|
||||
# !! CLOUDFLARE !!
|
||||
# The tunnel sits in front of this and Cloudflare caches .js by DEFAULT, which
|
||||
# includes /sw.js. If the edge caches the service worker, the update check can
|
||||
# never see a new one and the app is pinned to an old build with no remote way
|
||||
# to recover. The nginx headers below are necessary but NOT sufficient --
|
||||
# add a Cloudflare Cache Rule that BYPASSES cache for /sw.js (and /index.html)
|
||||
# before cutting over.
|
||||
#
|
||||
# Also confirm which Host header cloudflared sends. This vhost is keyed on
|
||||
# "192.168.1.7"; if the tunnel forwards the public hostname instead, nginx will
|
||||
# not match this vhost and will fall through to the default server. Either add
|
||||
# the public hostname to serverAliases or set the tunnel's httpHostHeader.
|
||||
#
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
|
||||
virtualHosts."192.168.1.7" = {
|
||||
# serverAliases = [ "notes.example.com" ]; # if the tunnel forwards the public host
|
||||
|
||||
# Published by deploy.sh; a symlink to the live release. Not in the repo.
|
||||
root = "/var/www/ss/current";
|
||||
|
||||
locations."/" = {
|
||||
tryFiles = "$uri $uri/ /index.html";
|
||||
};
|
||||
|
||||
# Never let the HTTP layer cache the service worker or the app shell.
|
||||
locations."= /sw.js".extraConfig = ''
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
locations."= /index.html".extraConfig = ''
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
# The manifest changes rarely but should not be pinned for a year.
|
||||
locations."= /manifest.json".extraConfig = ''
|
||||
add_header Cache-Control "no-cache";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
# Vite content-hashes everything under /assets, so these are immutable.
|
||||
locations."/assets/".extraConfig = ''
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
'';
|
||||
|
||||
locations."/api" = {
|
||||
proxyPass = "http://localhost:3000";
|
||||
};
|
||||
|
||||
locations."/socket" = {
|
||||
proxyPass = "http://localhost:3001";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Note on file access: nginx runs as its own user and needs read access to the
|
||||
# web root, with +x on every parent directory. Serving from /var/www/ss is what
|
||||
# makes that a non-issue -- the one-time `chmod 755 /var/www /var/www/ss` in
|
||||
# deploy.sh is the whole of it. This is the reason the root is not the repo
|
||||
# clone: a clone under /home/mab would require loosening the permissions on the
|
||||
# home directory itself.
|
||||
#
|
||||
# Uploads are NOT here. Express serves them from REPO_PATH/staticFiles at
|
||||
# /api/static via the proxy below, so they are untouched by deploys.
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy Solid Scribe. Run this ON the server, from inside the repo.
|
||||
#
|
||||
# cd /path/to/SolidScribe && ./deploy.sh
|
||||
#
|
||||
# Replaces buildAndUpdateProd.sh, which built the old Vue 2 client and rsynced a
|
||||
# tarball to /home/mab/pi -- a directory that no longer exists.
|
||||
#
|
||||
# Assumes:
|
||||
# - this repo is a git clone with a tracking branch
|
||||
# - node + pnpm are on PATH for the user running this (on NixOS that means
|
||||
# environment.systemPackages or the user's profile -- NOT only inside a
|
||||
# nix-shell, or this will fail at the install step)
|
||||
# - pm2 is already managing NoteServer (pm2 resurrect on boot)
|
||||
# - nginx serves $WEBROOT/current with an SPA fallback to index.html
|
||||
#
|
||||
# nginx is NOT touched here. On NixOS it is declarative -- see configs/.
|
||||
#
|
||||
# --- the web root --------------------------------------------------------
|
||||
#
|
||||
# The built client is published to /var/www/ss, NOT served out of the repo.
|
||||
# nginx runs as its own user, so serving from a clone under /home/mab meant
|
||||
# loosening the home directory's permissions for every parent dir. /var/www is
|
||||
# the conventional place for this and sidesteps the whole problem.
|
||||
#
|
||||
# Layout, which is the standard symlink-swap pattern:
|
||||
#
|
||||
# /var/www/ss/
|
||||
# releases/2026-10-28_1147/ <- one directory per deploy
|
||||
# releases/2026-10-28_0930/
|
||||
# current -> releases/2026-10-28_1147
|
||||
#
|
||||
# nginx points at the stable path $WEBROOT/current. Publishing is a rename of
|
||||
# that symlink, which is a single atomic syscall: no request ever sees a
|
||||
# half-copied directory, and no window exists where the root is missing.
|
||||
# Rolling back is the same rename pointing at the previous release.
|
||||
#
|
||||
# Only the *client* moves. The API server still runs from the repo under pm2,
|
||||
# and user uploads are still served by Express from $REPO/staticFiles at
|
||||
# /api/static -- deliberately, so deploys never touch user data.
|
||||
#
|
||||
# ONE-TIME SETUP on a new server (needs root):
|
||||
#
|
||||
# sudo mkdir -p /var/www/ss/releases
|
||||
# sudo chown -R "$USER" /var/www/ss
|
||||
# sudo chmod 755 /var/www /var/www/ss
|
||||
#
|
||||
# Override the location for a staging box with: WEBROOT=/var/www/ss-staging ./deploy.sh
|
||||
#
|
||||
# --- the database account ------------------------------------------------
|
||||
#
|
||||
# The API connects as a least-privilege account with DML rights only. This is
|
||||
# deliberate: the server fetches untrusted URLs for link previews, and a
|
||||
# service able to reshape or drop its own tables is a far larger blast radius
|
||||
# than that feature warrants.
|
||||
#
|
||||
# CREATE USER 'app'@'localhost' IDENTIFIED BY '<password>';
|
||||
# GRANT SELECT, INSERT, UPDATE, DELETE ON application.* TO 'app'@'localhost';
|
||||
# FLUSH PRIVILEGES;
|
||||
#
|
||||
# -- deliberately NOT granted: CREATE, ALTER, DROP, INDEX
|
||||
#
|
||||
# Then set DB_USER=app and DB_PASS=<password> in ~/.env. Note the asymmetry:
|
||||
# the admin scripts here (backupDatabase.sh, applyLastBackup.sh,
|
||||
# migrateDatabase.sh) still connect as root with $PROD_DB_PASS.
|
||||
#
|
||||
# DELETE is genuinely required -- the scraper drops shell attachment rows for
|
||||
# dead links and purges expired cache entries.
|
||||
#
|
||||
# --- schema changes ------------------------------------------------------
|
||||
#
|
||||
# Schema is managed by Prisma, for migrations and scaffolding ONLY -- the
|
||||
# running server never imports it and still talks to MariaDB through raw SQL.
|
||||
# The schema lives in prisma/schema.prisma; migrations in prisma/migrations/.
|
||||
#
|
||||
# Because the app holds no DDL, a deploy that introduces schema changes needs
|
||||
# one manual step, run once per environment as a database admin:
|
||||
#
|
||||
# nix-shell shell.nix --run ./migrateDatabase.sh
|
||||
#
|
||||
# nix-shell is required: the prisma CLI comes from nixpkgs (prisma_7, 7.8.0),
|
||||
# not from node_modules. Alternatively add prisma_7 to the server's
|
||||
# environment.systemPackages and call the script directly.
|
||||
#
|
||||
# It is idempotent, so re-running is safe. On a database that predates Prisma
|
||||
# the script detects the missing migration history and baselines it
|
||||
# automatically (marking 0_init applied rather than executing it) -- it does
|
||||
# NOT need to be done by hand.
|
||||
#
|
||||
# The API verifies the schema and the grants at boot; if either is missing it
|
||||
# logs the remedy and serves normally with link previews disabled.
|
||||
#
|
||||
# NEVER run `prisma migrate dev`, `prisma migrate reset` or `prisma db push`.
|
||||
# The first wants a shadow database; the latter two can drop data.
|
||||
#
|
||||
# DATABASE_URL is composed by the script from the admin credentials in ~/.env
|
||||
# and is never read by the application.
|
||||
#
|
||||
# Note: _prisma_migrations lives inside the `application` database, so the
|
||||
# least-privilege `app` user can write migration history (its grant is
|
||||
# database-wide). It holds no DDL, so it cannot reshape tables -- narrowing
|
||||
# this further would mean per-table grants, which is a separate project.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
WEBROOT="${WEBROOT:-/var/www/ss}"
|
||||
RELEASE="$WEBROOT/releases/$(date +%Y-%m-%d_%H%M%S)"
|
||||
# Number of old releases to keep around for rollback.
|
||||
KEEP_RELEASES=5
|
||||
|
||||
step(){ printf '\n\033[32m::--:: %s\033[0m\n' "$1"; }
|
||||
|
||||
# --- sanity checks before changing anything ------------------------------
|
||||
|
||||
for tool in git pnpm pm2 rsync; do
|
||||
command -v "$tool" >/dev/null || { echo "$tool is not on PATH" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Fail here rather than after a three-minute build. A missing web root is
|
||||
# almost always a server that never had the one-time setup above run on it.
|
||||
if [ ! -d "$WEBROOT/releases" ]; then
|
||||
echo "$WEBROOT/releases does not exist -- run the one-time setup at the top of this script." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w "$WEBROOT/releases" ]; then
|
||||
echo "$WEBROOT/releases is not writable by $(whoami) -- check the chown in the one-time setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Working tree has local modifications. Commit or stash them first:" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- pull ----------------------------------------------------------------
|
||||
|
||||
step "Pulling $(git rev-parse --abbrev-ref HEAD)..."
|
||||
# --ff-only: refuse to create a merge commit on the server. If this fails the
|
||||
# server has diverged and wants a human.
|
||||
git pull --ff-only
|
||||
|
||||
# --- dependencies --------------------------------------------------------
|
||||
|
||||
step "Installing server dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
step "Installing client dependencies..."
|
||||
pnpm install --frozen-lockfile --dir client
|
||||
|
||||
# --- build ---------------------------------------------------------------
|
||||
|
||||
# dist is gitignored, so the server builds it. The build stays inside the repo
|
||||
# and is only copied to $WEBROOT once it has been checked, so a failed build
|
||||
# cannot touch what is currently being served.
|
||||
step "Building client..."
|
||||
rm -rf client/dist
|
||||
pnpm --dir client exec vite build
|
||||
|
||||
# manifest.json is checked in under client/public and copied verbatim by Vite;
|
||||
# if it is missing, the PWA installs without icons, scope or a share target.
|
||||
for required in index.html sw.js manifest.json; do
|
||||
if [ ! -f "client/dist/$required" ]; then
|
||||
echo "Build output looks wrong (missing $required) -- $WEBROOT left untouched." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- publish -------------------------------------------------------------
|
||||
|
||||
step "Publishing to $RELEASE..."
|
||||
mkdir -p "$RELEASE"
|
||||
# Trailing slash on the source: copy the *contents* of dist into the release
|
||||
# dir. --delete is harmless on a fresh dir but keeps a re-run of the same
|
||||
# timestamp honest.
|
||||
rsync -a --delete client/dist/ "$RELEASE/"
|
||||
|
||||
# nginx reads these as its own user, so they must be world-readable.
|
||||
chmod -R a+rX "$RELEASE"
|
||||
|
||||
step "Pointing $WEBROOT/current at the new release..."
|
||||
# Two steps because `ln -sfn` over an existing symlink unlinks before relinking,
|
||||
# which leaves a brief window with no web root at all. Creating a scratch
|
||||
# symlink and renaming it over the old one is a single atomic rename(2).
|
||||
ln -sfn "$RELEASE" "$WEBROOT/current.new"
|
||||
mv -T "$WEBROOT/current.new" "$WEBROOT/current"
|
||||
|
||||
# --- prune ---------------------------------------------------------------
|
||||
|
||||
# Keep the most recent $KEEP_RELEASES for rollback, drop the rest. Never remove
|
||||
# whatever `current` actually resolves to, however old it is.
|
||||
live="$(readlink -f "$WEBROOT/current")"
|
||||
ls -1d "$WEBROOT"/releases/*/ 2>/dev/null \
|
||||
| sort -r \
|
||||
| tail -n +$((KEEP_RELEASES + 1)) \
|
||||
| while read -r stale; do
|
||||
[ "$(readlink -f "$stale")" = "$live" ] && continue
|
||||
echo " pruning $(basename "$stale")"
|
||||
rm -rf "$stale"
|
||||
done
|
||||
|
||||
# --- restart -------------------------------------------------------------
|
||||
|
||||
step "Reloading the API server..."
|
||||
# reload, not restart: graceful in cluster mode. --update-env so changes to
|
||||
# ecosystem.config.js are picked up.
|
||||
pm2 reload "$REPO/server/ecosystem.config.js" --env production --update-env
|
||||
|
||||
# Persist the process list so `pm2 resurrect` on boot gets the current state.
|
||||
pm2 save
|
||||
|
||||
step "Done."
|
||||
echo " serving $(readlink -f "$WEBROOT/current")"
|
||||
echo
|
||||
echo " To roll back, repoint the symlink at the previous release:"
|
||||
echo " ls $WEBROOT/releases"
|
||||
echo " ln -sfn $WEBROOT/releases/<older> $WEBROOT/current.new && mv -T $WEBROOT/current.new $WEBROOT/current"
|
||||
echo
|
||||
pm2 list
|
||||
@@ -1,11 +1,19 @@
|
||||
const path = '../../'
|
||||
const prefix = '/$1'
|
||||
//
|
||||
// The @alias map must mirror _moduleAliases in package.json, which is what
|
||||
// module-alias/register sets up at runtime.
|
||||
//
|
||||
// These were previously written as "../../server/..." relative paths, which
|
||||
// jest resolves relative to the requiring file - so they never actually
|
||||
// resolved, and every existing test sidestepped them with relative requires.
|
||||
// <rootDir> is depth-independent and works from any test directory.
|
||||
//
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
"@root/(.*)": ".",
|
||||
"@models/(.*)": path+"server/models"+prefix,
|
||||
"@routes/(.*)": path+"server/routes"+prefix,
|
||||
"@helpers/(.*)": path+"server/helpers"+prefix,
|
||||
"@config/(.*)": path+"server/config"+prefix,
|
||||
"^@root/(.*)$": "<rootDir>/$1",
|
||||
"^@models/(.*)$": "<rootDir>/server/models/$1",
|
||||
"^@routes/(.*)$": "<rootDir>/server/routes/$1",
|
||||
"^@helpers/(.*)$": "<rootDir>/server/helpers/$1",
|
||||
"^@config/(.*)$": "<rootDir>/server/config/$1",
|
||||
"^@workers/(.*)$": "<rootDir>/server/workers/$1",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Apply database migrations. Run from the repo root, INSIDE nix-shell (that is
|
||||
# where the prisma CLI lives).
|
||||
#
|
||||
# nix-shell shell.nix --run ./migrateDatabase.sh
|
||||
#
|
||||
# Works both ways round: it applies pending migrations to an existing database,
|
||||
# and builds a complete one from empty. Safe to re-run -- a second run is a
|
||||
# no-op. Replaces migrateDatabase.sh.
|
||||
#
|
||||
# --- why this is a separate step -----------------------------------------
|
||||
#
|
||||
# The application connects as a least-privilege account (production uses `app`)
|
||||
# holding DML rights only -- no CREATE, ALTER or DROP. That is deliberate: the
|
||||
# server fetches untrusted URLs for link previews, and a service able to reshape
|
||||
# its own tables is a far larger blast radius than that feature warrants.
|
||||
#
|
||||
# So the app never applies DDL. It verifies the schema at boot
|
||||
# (server/helpers/Schema.js) and disables link previews if anything is missing.
|
||||
#
|
||||
# --- authoring a new migration -------------------------------------------
|
||||
#
|
||||
# NEVER run `prisma migrate dev`. It can decide the database has drifted and
|
||||
# offer to RESET it, which on a box holding a production data restore would
|
||||
# destroy real data. It also wants a shadow database. Instead:
|
||||
#
|
||||
# 1. Edit prisma/schema.prisma
|
||||
# 2. NAME=add_whatever
|
||||
# DIR="prisma/migrations/$(date +%Y%m%d%H%M%S)_$NAME"
|
||||
# mkdir -p "$DIR"
|
||||
# prisma migrate diff --from-config-datasource \
|
||||
# --to-schema prisma/schema.prisma --script > "$DIR/migration.sql"
|
||||
# 3. READ the generated SQL -- this review is what makes the approach safe
|
||||
# 4. ./migrateDatabase.sh
|
||||
#
|
||||
# Note: Prisma emits `COLLATE utf8mb4_unicode_ci`, but this database is
|
||||
# `utf8mb4_general_ci` throughout, and `prisma migrate diff` does NOT report the
|
||||
# difference. Fix the collation in any generated migration by hand. This script
|
||||
# checks for drift at the end.
|
||||
#
|
||||
# --- credentials ---------------------------------------------------------
|
||||
#
|
||||
# Same convention as applyLastBackup.sh / backupDatabase.sh: source ~/.env and
|
||||
# connect as root with $PROD_DB_PASS. Falls back to passwordless root (the dev
|
||||
# case, where shell.nix also exports DATABASE_URL). Override with:
|
||||
#
|
||||
# DB_ADMIN_USER=admin DB_ADMIN_PASS=secret ./migrateDatabase.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
DB_NAME="application" # hardcoded in server/config/database.js too
|
||||
|
||||
# --- preconditions -------------------------------------------------------
|
||||
|
||||
if ! command -v prisma >/dev/null 2>&1; then
|
||||
echo "prisma is not on PATH -- run this inside nix-shell:" >&2
|
||||
echo " nix-shell shell.nix --run ./migrateDatabase.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v mariadb >/dev/null 2>&1 && ! command -v mysql >/dev/null 2>&1; then
|
||||
echo "no mariadb/mysql client on PATH -- run this inside nix-shell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v mariadb >/dev/null 2>&1; then MYSQL_BIN="mariadb"; else MYSQL_BIN="mysql"; fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "node is not on PATH -- needed to percent-encode the database password." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.env"
|
||||
fi
|
||||
|
||||
ADMIN_USER="${DB_ADMIN_USER:-root}"
|
||||
ADMIN_PASS="${DB_ADMIN_PASS:-${PROD_DB_PASS:-}}"
|
||||
|
||||
mysql_args=(--user "$ADMIN_USER")
|
||||
[ -n "$ADMIN_PASS" ] && mysql_args+=(--password="$ADMIN_PASS")
|
||||
|
||||
LOCAL_SOCKET="$REPO/.mysql/mysql.sock"
|
||||
[ -S "$LOCAL_SOCKET" ] && mysql_args+=(--socket="$LOCAL_SOCKET")
|
||||
|
||||
# Prisma needs a URL, and it must carry the ADMIN credentials -- never the
|
||||
# app account, which holds no DDL.
|
||||
DB_ADMIN_HOST="${DB_ADMIN_HOST:-127.0.0.1}"
|
||||
DB_ADMIN_PORT="${DB_ADMIN_PORT:-3306}"
|
||||
|
||||
#
|
||||
# Percent-encode the password. Production passwords routinely contain @ : / ? #
|
||||
# or %, every one of which silently corrupts a URL -- an unencoded `@` would
|
||||
# truncate the host and produce a baffling connection error rather than an
|
||||
# obvious one.
|
||||
#
|
||||
encode_component() {
|
||||
node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$1"
|
||||
}
|
||||
|
||||
ADMIN_USER_ENC="$(encode_component "$ADMIN_USER")"
|
||||
|
||||
if [ -n "$ADMIN_PASS" ]; then
|
||||
ADMIN_PASS_ENC="$(encode_component "$ADMIN_PASS")"
|
||||
export DATABASE_URL="mysql://${ADMIN_USER_ENC}:${ADMIN_PASS_ENC}@${DB_ADMIN_HOST}:${DB_ADMIN_PORT}/${DB_NAME}"
|
||||
else
|
||||
export DATABASE_URL="mysql://${ADMIN_USER_ENC}@${DB_ADMIN_HOST}:${DB_ADMIN_PORT}/${DB_NAME}"
|
||||
fi
|
||||
|
||||
# Keep the CLI from phoning checkpoint.prisma.io for update checks.
|
||||
export CHECKPOINT_DISABLE=1
|
||||
|
||||
if ! "$MYSQL_BIN" "${mysql_args[@]}" -e "SELECT 1" >/dev/null 2>&1; then
|
||||
echo "Cannot connect to the database as '$ADMIN_USER'." >&2
|
||||
echo "Set PROD_DB_PASS in ~/.env, or pass DB_ADMIN_USER / DB_ADMIN_PASS." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#
|
||||
# Collation is explicit because the server default is utf8mb4_unicode_ci while
|
||||
# every application table is utf8mb4_general_ci. A table created later without
|
||||
# an explicit COLLATE would otherwise inherit the wrong one, changing string
|
||||
# comparison and FULLTEXT ranking -- and Prisma neither models collation nor
|
||||
# reports the difference in `migrate diff`.
|
||||
#
|
||||
# For a database that already exists with the wrong default, the one-time
|
||||
# non-destructive fix is:
|
||||
# ALTER DATABASE application CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
#
|
||||
"$MYSQL_BIN" "${mysql_args[@]}" -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` \
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
|
||||
|
||||
sql() { "$MYSQL_BIN" "${mysql_args[@]}" -N -B "$DB_NAME" -e "$1" 2>/dev/null; }
|
||||
|
||||
echo '-------'
|
||||
echo "Migrating '$DB_NAME' as '$ADMIN_USER'"
|
||||
echo '-------'
|
||||
|
||||
# --- baseline an existing, pre-Prisma database ---------------------------
|
||||
#
|
||||
# This is the single most dangerous moment in adopting Prisma. A database that
|
||||
# already has the schema but no _prisma_migrations table must have the baseline
|
||||
# marked applied, NOT executed -- running 0_init against it would fail on
|
||||
# existing tables. Detect and handle it here rather than leaving it as a manual
|
||||
# step someone forgets.
|
||||
#
|
||||
HAS_MIGRATION_TABLE=$(sql "SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='_prisma_migrations'" || echo 0)
|
||||
HAS_EXISTING_SCHEMA=$(sql "SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='attachment'" || echo 0)
|
||||
|
||||
if [ "${HAS_MIGRATION_TABLE:-0}" -eq 0 ] && [ "${HAS_EXISTING_SCHEMA:-0}" -gt 0 ]; then
|
||||
echo "Existing schema found with no migration history -- baselining."
|
||||
|
||||
#
|
||||
# Report drift BEFORE recording the baseline.
|
||||
#
|
||||
# `migrate resolve` records a migration without executing it, so baselining
|
||||
# a database that does not actually match the schema leaves it permanently
|
||||
# short of those objects while Prisma reports everything as applied. That
|
||||
# is exactly how production ended up missing scrape_cache: this script
|
||||
# previously inferred "the attachment table exists" => "the whole schema
|
||||
# exists", which is not the same claim.
|
||||
#
|
||||
# Later migrations are additive and idempotent, so drift is recoverable --
|
||||
# but the operator should see it rather than discover it from a log line
|
||||
# days later.
|
||||
#
|
||||
if ! prisma migrate diff \
|
||||
--from-config-datasource \
|
||||
--to-schema prisma/schema.prisma \
|
||||
--exit-code >/dev/null 2>&1
|
||||
then
|
||||
echo ""
|
||||
echo "NOTE: this database does not yet match prisma/schema.prisma."
|
||||
echo " Baselining 0_init anyway, then applying later migrations to"
|
||||
echo " converge it. Differences:"
|
||||
prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma \
|
||||
| sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Marking 0_init as already applied (NOT executing it)."
|
||||
prisma migrate resolve --applied 0_init
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- apply ---------------------------------------------------------------
|
||||
|
||||
prisma migrate deploy
|
||||
|
||||
# --- seed ----------------------------------------------------------------
|
||||
|
||||
if [ -f prisma/seed.sql ]; then
|
||||
echo ""
|
||||
echo "Applying reference data (prisma/seed.sql)..."
|
||||
"$MYSQL_BIN" "${mysql_args[@]}" "$DB_NAME" < prisma/seed.sql
|
||||
fi
|
||||
|
||||
# --- verify --------------------------------------------------------------
|
||||
#
|
||||
# Print proof rather than leaving the operator to trust us.
|
||||
#
|
||||
echo ""
|
||||
echo '-------'
|
||||
echo 'Verification'
|
||||
echo '-------'
|
||||
|
||||
prisma migrate status
|
||||
|
||||
echo ""
|
||||
"$MYSQL_BIN" "${mysql_args[@]}" "$DB_NAME" --table -e "
|
||||
SELECT 'attachment FULLTEXT index' AS check_name,
|
||||
IFNULL(MAX(INDEX_TYPE), 'MISSING') AS result
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME = 'attachment_text_index'
|
||||
UNION ALL
|
||||
SELECT 'tables',
|
||||
CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations'
|
||||
UNION ALL
|
||||
SELECT 'attachment_type seed rows',
|
||||
CAST(COUNT(*) AS CHAR) FROM attachment_type
|
||||
UNION ALL
|
||||
-- _prisma_migrations is Prisma's own bookkeeping table and is created with
|
||||
-- its default collation; only application tables are checked here.
|
||||
SELECT 'non-general_ci app tables (expect 0)',
|
||||
CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME <> '_prisma_migrations'
|
||||
AND TABLE_COLLATION <> 'utf8mb4_general_ci';
|
||||
"
|
||||
|
||||
#
|
||||
# Assert that everything the application actually needs now exists.
|
||||
#
|
||||
# This is the check that was missing. `prisma migrate deploy` reporting success
|
||||
# only means the recorded migrations ran -- it says nothing about whether the
|
||||
# database ended up in the intended shape, which is precisely how production
|
||||
# could pass this script and still log
|
||||
# "Scraper: DISABLED - missing table scrape_cache ...".
|
||||
#
|
||||
# Mirrors server/helpers/Schema.js's boot verification, so a green run here
|
||||
# means a green boot there.
|
||||
#
|
||||
FAILURES=0
|
||||
require_object() {
|
||||
local label="$1" count="$2"
|
||||
if [ "${count:-0}" -eq 0 ]; then
|
||||
echo "FAILED: missing $label" >&2
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
require_object "table scrape_cache" "$(sql "SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='scrape_cache'")"
|
||||
|
||||
for column in scrape_state scrape_attempts enqueued_at; do
|
||||
require_object "attachment.$column" "$(sql "SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='attachment' AND COLUMN_NAME='$column'")"
|
||||
done
|
||||
|
||||
require_object "attachment index attachment_scrape_state" "$(sql "SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='attachment' AND INDEX_NAME='attachment_scrape_state'")"
|
||||
|
||||
# Checked by TYPE, not just by name: a BTREE index called attachment_text_index
|
||||
# would make MATCH..AGAINST return nothing at all, with no error anywhere.
|
||||
FT=$(sql "SELECT IFNULL(MAX(INDEX_TYPE),'MISSING') FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='$DB_NAME' AND INDEX_NAME='attachment_text_index'")
|
||||
if [ "$FT" != "FULLTEXT" ]; then
|
||||
echo "FAILED: attachment_text_index is '$FT', expected FULLTEXT." >&2
|
||||
echo "Attachment search depends on MATCH..AGAINST and would silently return nothing." >&2
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
|
||||
if [ "$FAILURES" -gt 0 ]; then
|
||||
echo "" >&2
|
||||
echo "$FAILURES required object(s) missing after migrating -- the scraper will stay disabled." >&2
|
||||
echo "This should not happen; check 'prisma migrate status' and the migration SQL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo '-------'
|
||||
echo 'Done. Restart the API so it re-verifies the schema:'
|
||||
echo ' pm2 restart NoteServer'
|
||||
echo '-------'
|
||||
@@ -10,7 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"cheerio": "^1.1.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^5.1.3",
|
||||
@@ -22,8 +22,6 @@
|
||||
"mysql2": "^2.2.5",
|
||||
"node-tesseract-ocr": "^2.0.0",
|
||||
"qrcode": "^1.4.4",
|
||||
"request": "^2.88.2",
|
||||
"request-promise": "^4.2.6",
|
||||
"socket.io": "^2.3.0",
|
||||
"speakeasy": "^2.0.0"
|
||||
},
|
||||
@@ -32,7 +30,8 @@
|
||||
"@models": "server/models",
|
||||
"@routes": "server/routes",
|
||||
"@helpers": "server/helpers",
|
||||
"@config": "server/config"
|
||||
"@config": "server/config",
|
||||
"@workers": "server/workers"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Prisma CLI configuration.
|
||||
//
|
||||
// Prisma 7 removed `url` from the datasource block in schema.prisma; the
|
||||
// connection string for migrations lives here instead.
|
||||
//
|
||||
// This file configures the CLI only. The running server does not use Prisma --
|
||||
// it talks to MariaDB through server/config/database.js (mysql2) with raw SQL.
|
||||
// Prisma is a development and admin tool here, for schema migrations and
|
||||
// scaffolding.
|
||||
//
|
||||
// DATABASE_URL must point at an account with DDL rights. In dev that is the
|
||||
// root user exported by shell.nix; in production migrateDatabase.sh composes it
|
||||
// from the admin credentials in ~/.env. It must NEVER be the least-privilege
|
||||
// `app` account the server runs as, which deliberately holds no CREATE/ALTER.
|
||||
//
|
||||
// .mjs rather than .js because this project is CommonJS and Prisma's example
|
||||
// config is ESM; the explicit extension avoids any ambiguity.
|
||||
//
|
||||
export default {
|
||||
schema: 'prisma/schema.prisma',
|
||||
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
|
||||
datasource: {
|
||||
// process.env directly rather than env() -- the helper throws when the
|
||||
// variable is unset, which makes the failure harder to read.
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
--
|
||||
-- Repair: converge environments that were baselined before the scrape objects
|
||||
-- existed.
|
||||
--
|
||||
-- WHY THIS EXISTS
|
||||
--
|
||||
-- The 0_init baseline was generated from a database that already had
|
||||
-- scrape_cache and the attachment.scrape_* columns, on the understanding that
|
||||
-- every environment was in that state. Production was not. Because
|
||||
-- `prisma migrate resolve --applied 0_init` records a migration WITHOUT
|
||||
-- executing it, production ended up with 0_init marked applied while those
|
||||
-- objects were never created -- so the scraper logged:
|
||||
--
|
||||
-- Scraper: DISABLED - missing table scrape_cache, attachment.scrape_state, ...
|
||||
--
|
||||
-- Re-running 0_init is not an option (it would try to CREATE TABLE over ~2200
|
||||
-- rows of live data), so this additive, idempotent migration brings any
|
||||
-- environment up to the intended schema instead.
|
||||
--
|
||||
-- Every statement uses IF NOT EXISTS, so this is a clean no-op on environments
|
||||
-- that already had these objects. MariaDB 11.4 supports that syntax for columns
|
||||
-- and indexes; both dev and production are MariaDB, so this is safe here. It
|
||||
-- would NOT be valid on MySQL 8.
|
||||
--
|
||||
-- The definitions below are identical to those in 0_init -- deliberately so,
|
||||
-- because the end state must be the same whichever path an environment took.
|
||||
--
|
||||
|
||||
-- One row per scraped URL, shared across users and notes.
|
||||
--
|
||||
-- url_hash carries the UNIQUE index rather than `url` because a UNIQUE on
|
||||
-- varchar(1000) utf8mb4 is 4000 bytes and exceeds InnoDB's 3072-byte key limit.
|
||||
--
|
||||
-- Collation is explicit: Prisma renders utf8mb4_unicode_ci, but every table in
|
||||
-- this database is utf8mb4_general_ci, and `prisma migrate diff` does not
|
||||
-- report the difference.
|
||||
CREATE TABLE IF NOT EXISTS `scrape_cache` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`url_hash` CHAR(64) NOT NULL,
|
||||
`url` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
`canonical_url` VARCHAR(1000) NULL,
|
||||
`status` SMALLINT NOT NULL DEFAULT 0,
|
||||
`title` VARCHAR(512) NULL,
|
||||
`site_name` VARCHAR(255) NULL,
|
||||
`description` MEDIUMTEXT NULL,
|
||||
`keywords` VARCHAR(512) NULL,
|
||||
`search_text` MEDIUMTEXT NULL,
|
||||
`image_url` VARCHAR(1000) NULL,
|
||||
`file_location` VARCHAR(500) NULL,
|
||||
`etag` VARCHAR(255) NULL,
|
||||
`last_modified` VARCHAR(128) NULL,
|
||||
`content_type` VARCHAR(128) NULL,
|
||||
`fetch_state` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
`failure_count` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_error` VARCHAR(255) NULL,
|
||||
`fetched_at` INTEGER NULL,
|
||||
`expires_at` INTEGER NULL,
|
||||
|
||||
UNIQUE INDEX `scrape_cache_url_hash`(`url_hash`),
|
||||
INDEX `scrape_cache_expires_at`(`expires_at`),
|
||||
INDEX `scrape_cache_fetch_state`(`fetch_state`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- Per-attachment scrape lifecycle, so the in-memory queue survives a restart.
|
||||
--
|
||||
-- scrape_state: 0 = pending, 1 = fetching, 2 = done, 3 = failed.
|
||||
-- DEFAULT 2 is load-bearing -- it keeps every pre-existing attachment row
|
||||
-- (~2200 in production) out of the scraper's boot recovery sweep.
|
||||
ALTER TABLE `attachment` ADD COLUMN IF NOT EXISTS `scrape_state` TINYINT NOT NULL DEFAULT 2;
|
||||
ALTER TABLE `attachment` ADD COLUMN IF NOT EXISTS `scrape_attempts` TINYINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE `attachment` ADD COLUMN IF NOT EXISTS `enqueued_at` INTEGER NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `attachment_scrape_state` ON `attachment` (`scrape_state`, `enqueued_at`);
|
||||
@@ -0,0 +1,211 @@
|
||||
--
|
||||
-- Baseline migration: the schema as it existed when Prisma was adopted.
|
||||
--
|
||||
-- Generated with:
|
||||
-- prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script
|
||||
--
|
||||
-- HAND-CORRECTED, and it matters: Prisma renders `COLLATE utf8mb4_unicode_ci`
|
||||
-- for MySQL, but every live table is `utf8mb4_general_ci`. Prisma does not
|
||||
-- model collation and its own `migrate diff` does NOT report the difference, so
|
||||
-- without this correction a database rebuilt from empty would silently differ
|
||||
-- from production in string comparison, sorting and FULLTEXT ranking.
|
||||
-- If you regenerate this file, re-apply the collation fix. There is a test for
|
||||
-- it in server/tests/schema/migrations.test.js.
|
||||
--
|
||||
-- Things Prisma cannot express, which therefore need handling elsewhere:
|
||||
-- * the DATABASE-level default collation -- set by migrateDatabase.sh when it
|
||||
-- creates the database, since tables created later without an explicit
|
||||
-- COLLATE would otherwise inherit the server default
|
||||
-- * ENGINE=InnoDB (relies on the server default, which is InnoDB)
|
||||
-- * AUTO_INCREMENT start values (cosmetic; a fresh database starting at 1 is
|
||||
-- correct)
|
||||
-- * the COMMENT on note_raw_text.text
|
||||
-- * row data -- see prisma/seed.sql for the attachment_type lookup rows
|
||||
--
|
||||
-- Context carried over from the migration this replaces: scrape_cache.url_hash
|
||||
-- carries the UNIQUE index rather than `url` because a UNIQUE on
|
||||
-- varchar(1000) utf8mb4 is 4000 bytes and exceeds InnoDB's 3072-byte key limit;
|
||||
-- and attachment.scrape_state DEFAULT 2 ("done") is load-bearing -- it keeps
|
||||
-- pre-existing attachment rows out of the scraper's boot recovery sweep.
|
||||
--
|
||||
-- On environments that already had this schema (dev and production), this file
|
||||
-- is NOT executed -- it is marked applied with `prisma migrate resolve`.
|
||||
--
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `attachment` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`note_id` INTEGER UNSIGNED NULL,
|
||||
`user_id` INTEGER UNSIGNED NOT NULL,
|
||||
`attachment_type` INTEGER UNSIGNED NULL,
|
||||
`text` MEDIUMTEXT NULL,
|
||||
`url` VARCHAR(1000) NULL DEFAULT '',
|
||||
`last_indexed` INTEGER NULL,
|
||||
`file_location` VARCHAR(500) NULL,
|
||||
`visible` TINYINT NULL DEFAULT 1,
|
||||
`scrape_state` TINYINT NOT NULL DEFAULT 2,
|
||||
`scrape_attempts` TINYINT NOT NULL DEFAULT 0,
|
||||
`enqueued_at` INTEGER NULL,
|
||||
|
||||
INDEX `attachment_scrape_state`(`scrape_state`, `enqueued_at`),
|
||||
INDEX `attachment_type`(`attachment_type`),
|
||||
INDEX `note_id`(`note_id`),
|
||||
INDEX `user_id`(`user_id`),
|
||||
FULLTEXT INDEX `attachment_text_index`(`text`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `attachment_type` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`mime_type` VARCHAR(500) NULL,
|
||||
`extension` VARCHAR(10) NULL,
|
||||
`name` VARCHAR(50) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `note` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER UNSIGNED NULL,
|
||||
`note_raw_text_id` INTEGER NULL,
|
||||
`opened` INTEGER NULL,
|
||||
`created` INTEGER NULL,
|
||||
`pinned` INTEGER NULL DEFAULT 0,
|
||||
`archived` INTEGER NULL DEFAULT 0,
|
||||
`trashed` TINYINT NULL DEFAULT 0,
|
||||
`color` VARCHAR(500) NULL,
|
||||
`shared` INTEGER NULL DEFAULT 0,
|
||||
`share_user_id` INTEGER NULL,
|
||||
`quick_note` INTEGER NULL DEFAULT 0,
|
||||
`indexed` TINYINT NULL DEFAULT 0,
|
||||
`snippet` VARCHAR(1300) NULL,
|
||||
`snippet_salt` VARCHAR(1300) NULL,
|
||||
`encrypted_share_password_key` VARCHAR(1300) NULL,
|
||||
|
||||
INDEX `user_note`(`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `note_raw_text` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`text` LONGTEXT NULL,
|
||||
`updated` BIGINT NULL,
|
||||
`salt` VARCHAR(1300) NULL,
|
||||
`last_decrypted_date` INTEGER NULL,
|
||||
`decrypt_attempts_count` INTEGER NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `note_tag` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`note_id` INTEGER UNSIGNED NULL,
|
||||
`tag_id` INTEGER UNSIGNED NULL,
|
||||
`user_id` INTEGER UNSIGNED NULL,
|
||||
|
||||
INDEX `note_id`(`note_id`),
|
||||
INDEX `tag_id`(`tag_id`),
|
||||
INDEX `user_id`(`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `scrape_cache` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`url_hash` CHAR(64) NOT NULL,
|
||||
`url` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
`canonical_url` VARCHAR(1000) NULL,
|
||||
`status` SMALLINT NOT NULL DEFAULT 0,
|
||||
`title` VARCHAR(512) NULL,
|
||||
`site_name` VARCHAR(255) NULL,
|
||||
`description` MEDIUMTEXT NULL,
|
||||
`keywords` VARCHAR(512) NULL,
|
||||
`search_text` MEDIUMTEXT NULL,
|
||||
`image_url` VARCHAR(1000) NULL,
|
||||
`file_location` VARCHAR(500) NULL,
|
||||
`etag` VARCHAR(255) NULL,
|
||||
`last_modified` VARCHAR(128) NULL,
|
||||
`content_type` VARCHAR(128) NULL,
|
||||
`fetch_state` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
`failure_count` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_error` VARCHAR(255) NULL,
|
||||
`fetched_at` INTEGER NULL,
|
||||
`expires_at` INTEGER NULL,
|
||||
|
||||
UNIQUE INDEX `scrape_cache_url_hash`(`url_hash`),
|
||||
INDEX `scrape_cache_expires_at`(`expires_at`),
|
||||
INDEX `scrape_cache_fetch_state`(`fetch_state`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `tag` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`text` VARCHAR(400) NULL,
|
||||
`hash` INTEGER NULL,
|
||||
|
||||
INDEX `hash`(`hash`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(150) NOT NULL DEFAULT '',
|
||||
`password` TEXT NOT NULL,
|
||||
`salt` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`iterations` INTEGER NOT NULL,
|
||||
`last_login` DATETIME(0) NULL,
|
||||
`created` DATETIME(0) NULL,
|
||||
`two_fa_secret` VARCHAR(50) NULL,
|
||||
`two_fa_enabled` TINYINT NULL DEFAULT 0,
|
||||
`two_fa_every_login` TINYINT NULL DEFAULT 0,
|
||||
`pushkey` VARCHAR(100) NULL,
|
||||
|
||||
UNIQUE INDEX `id_UNIQUE`(`id`),
|
||||
UNIQUE INDEX `username_UNIQUE`(`username`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_active_session` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`salt` VARCHAR(100) NULL,
|
||||
`encrypted_master_password` VARCHAR(500) NULL,
|
||||
`created` INTEGER NULL,
|
||||
`uses` INTEGER NULL,
|
||||
`user_hash` VARCHAR(500) NULL,
|
||||
`active` TINYINT NULL DEFAULT 1,
|
||||
`session_id` VARCHAR(100) NULL,
|
||||
`last_used` INTEGER NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_encrypted_search_index` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NULL,
|
||||
`salt` VARCHAR(100) NULL,
|
||||
`index` LONGTEXT NULL,
|
||||
`last_update` INTEGER NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_key` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER NULL,
|
||||
`salt` VARCHAR(1300) NULL,
|
||||
`key` VARCHAR(5000) NULL,
|
||||
`created` INTEGER NULL,
|
||||
`public_key` VARCHAR(5000) NULL,
|
||||
`private_key_encrypted` VARCHAR(5000) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
@@ -0,0 +1,151 @@
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
}
|
||||
|
||||
model attachment {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
note_id Int? @db.UnsignedInt
|
||||
user_id Int @db.UnsignedInt
|
||||
attachment_type Int? @db.UnsignedInt
|
||||
text String? @db.MediumText
|
||||
url String? @default("") @db.VarChar(1000)
|
||||
last_indexed Int?
|
||||
file_location String? @db.VarChar(500)
|
||||
visible Int? @default(1) @db.TinyInt
|
||||
scrape_state Int @default(2) @db.TinyInt
|
||||
scrape_attempts Int @default(0) @db.TinyInt
|
||||
enqueued_at Int?
|
||||
|
||||
@@index([scrape_state, enqueued_at], map: "attachment_scrape_state")
|
||||
@@index([attachment_type], map: "attachment_type")
|
||||
@@index([note_id], map: "note_id")
|
||||
@@index([user_id], map: "user_id")
|
||||
@@fulltext([text], map: "attachment_text_index")
|
||||
}
|
||||
|
||||
model attachment_type {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
mime_type String? @db.VarChar(500)
|
||||
extension String? @db.VarChar(10)
|
||||
name String? @db.VarChar(50)
|
||||
}
|
||||
|
||||
model note {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
user_id Int? @db.UnsignedInt
|
||||
note_raw_text_id Int?
|
||||
opened Int?
|
||||
created Int?
|
||||
pinned Int? @default(0)
|
||||
archived Int? @default(0)
|
||||
trashed Int? @default(0) @db.TinyInt
|
||||
color String? @db.VarChar(500)
|
||||
shared Int? @default(0)
|
||||
share_user_id Int?
|
||||
quick_note Int? @default(0)
|
||||
indexed Int? @default(0) @db.TinyInt
|
||||
snippet String? @db.VarChar(1300)
|
||||
snippet_salt String? @db.VarChar(1300)
|
||||
encrypted_share_password_key String? @db.VarChar(1300)
|
||||
|
||||
@@index([user_id], map: "user_note")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model note_raw_text {
|
||||
id Int @id @default(autoincrement())
|
||||
text String? @db.LongText
|
||||
updated BigInt?
|
||||
salt String? @db.VarChar(1300)
|
||||
last_decrypted_date Int?
|
||||
decrypt_attempts_count Int? @default(0)
|
||||
}
|
||||
|
||||
model note_tag {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
note_id Int? @db.UnsignedInt
|
||||
tag_id Int? @db.UnsignedInt
|
||||
user_id Int? @db.UnsignedInt
|
||||
|
||||
@@index([note_id], map: "note_id")
|
||||
@@index([tag_id], map: "tag_id")
|
||||
@@index([user_id], map: "user_id")
|
||||
}
|
||||
|
||||
model scrape_cache {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
url_hash String @unique(map: "scrape_cache_url_hash") @db.Char(64)
|
||||
url String @default("") @db.VarChar(1000)
|
||||
canonical_url String? @db.VarChar(1000)
|
||||
status Int @default(0) @db.SmallInt
|
||||
title String? @db.VarChar(512)
|
||||
site_name String? @db.VarChar(255)
|
||||
description String? @db.MediumText
|
||||
keywords String? @db.VarChar(512)
|
||||
search_text String? @db.MediumText
|
||||
image_url String? @db.VarChar(1000)
|
||||
file_location String? @db.VarChar(500)
|
||||
etag String? @db.VarChar(255)
|
||||
last_modified String? @db.VarChar(128)
|
||||
content_type String? @db.VarChar(128)
|
||||
fetch_state String @default("pending") @db.VarChar(20)
|
||||
failure_count Int @default(0)
|
||||
last_error String? @db.VarChar(255)
|
||||
fetched_at Int?
|
||||
expires_at Int?
|
||||
|
||||
@@index([expires_at], map: "scrape_cache_expires_at")
|
||||
@@index([fetch_state], map: "scrape_cache_fetch_state")
|
||||
}
|
||||
|
||||
model tag {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
text String? @db.VarChar(400)
|
||||
hash Int?
|
||||
|
||||
@@index([hash], map: "hash")
|
||||
}
|
||||
|
||||
model user {
|
||||
id Int @id @unique(map: "id_UNIQUE") @default(autoincrement()) @db.UnsignedInt
|
||||
username String @unique(map: "username_UNIQUE") @default("") @db.VarChar(150)
|
||||
password String @db.Text
|
||||
salt String @default("") @db.VarChar(500)
|
||||
iterations Int
|
||||
last_login DateTime? @db.DateTime(0)
|
||||
created DateTime? @db.DateTime(0)
|
||||
two_fa_secret String? @db.VarChar(50)
|
||||
two_fa_enabled Int? @default(0) @db.TinyInt
|
||||
two_fa_every_login Int? @default(0) @db.TinyInt
|
||||
pushkey String? @db.VarChar(100)
|
||||
}
|
||||
|
||||
model user_active_session {
|
||||
id Int @id @default(autoincrement())
|
||||
salt String? @db.VarChar(100)
|
||||
encrypted_master_password String? @db.VarChar(500)
|
||||
created Int?
|
||||
uses Int?
|
||||
user_hash String? @db.VarChar(500)
|
||||
active Int? @default(1) @db.TinyInt
|
||||
session_id String? @db.VarChar(100)
|
||||
last_used Int?
|
||||
}
|
||||
|
||||
model user_encrypted_search_index {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int?
|
||||
salt String? @db.VarChar(100)
|
||||
index String? @db.LongText
|
||||
last_update Int?
|
||||
}
|
||||
|
||||
model user_key {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int?
|
||||
salt String? @db.VarChar(1300)
|
||||
key String? @db.VarChar(5000)
|
||||
created Int?
|
||||
public_key String? @db.VarChar(5000)
|
||||
private_key_encrypted String? @db.VarChar(5000)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
--
|
||||
-- Reference data required for a working database.
|
||||
--
|
||||
-- Applied by ./migrateDatabase.sh after migrations. Kept out of the migration
|
||||
-- history on purpose: environments that were baselined with
|
||||
-- `prisma migrate resolve` never execute 0_init, so seed rows placed inside it
|
||||
-- would silently never exist there.
|
||||
--
|
||||
-- Idempotent, so re-running is harmless.
|
||||
--
|
||||
|
||||
-- attachment_type is a lookup table the application joins against.
|
||||
-- attachment_type = 1 is hardcoded for scraped URLs, 2 for uploaded images,
|
||||
-- 3 for PDFs (see server/models/Attachment.js). Without these rows a freshly
|
||||
-- scaffolded database creates attachments that reference nothing.
|
||||
INSERT INTO `attachment_type` (`id`, `mime_type`, `extension`, `name`) VALUES
|
||||
(1, NULL, NULL, 'URL'),
|
||||
(2, NULL, '.jpg', 'Image - Jpg'),
|
||||
(3, NULL, '.pdf', 'PDF'),
|
||||
(4, NULL, '.doc', 'DOC')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`mime_type` = VALUES(`mime_type`),
|
||||
`extension` = VALUES(`extension`),
|
||||
`name` = VALUES(`name`);
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// All scraper tunables in one place, so nothing downstream is a magic number.
|
||||
//
|
||||
// Env overrides exist for the two kill switches only. Everything else is a
|
||||
// constant on purpose: the politeness numbers are the difference between
|
||||
// looking like a browser and looking like a bot, and they should not drift
|
||||
// per-environment without a code change.
|
||||
//
|
||||
|
||||
const isTest = process.env.NODE_ENV === 'test'
|
||||
|
||||
const config = {
|
||||
// Kill switches
|
||||
enabled: process.env.SCRAPE_ENABLED !== '0',
|
||||
// The background worker is off under Jest so tests drive the queue
|
||||
// deterministically via ScrapeQueue.drain() instead of racing it.
|
||||
workerEnabled: process.env.SCRAPE_WORKER ? process.env.SCRAPE_WORKER === '1' : !isTest,
|
||||
|
||||
// Timeouts
|
||||
pageTimeoutMs: 8000, // single HTTP request
|
||||
totalBudgetMs: 25000, // all hops + retries for one URL
|
||||
imageTimeoutMs: 10000,
|
||||
|
||||
// Size caps. Content-Length is optional and can lie, so these are also
|
||||
// enforced while streaming the body.
|
||||
maxHtmlBytes: 2 * 1024 * 1024,
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxRobotsBytes: 100 * 1024,
|
||||
|
||||
// Redirects
|
||||
maxRedirects: 5,
|
||||
|
||||
// Retries. Only for transient classes - a 404 is an answer, not a failure.
|
||||
retries: 2,
|
||||
backoffBaseMs: 500,
|
||||
backoffMaxMs: 8000,
|
||||
maxRetryAfterMs: 60 * 1000,
|
||||
|
||||
// Politeness. One request at a time per host, spaced out, is what actually
|
||||
// stops us looking like a crawler.
|
||||
maxConcurrentFetches: 4,
|
||||
perHostConcurrency: 1,
|
||||
perHostMinSpacingMs: 1000,
|
||||
perHostMaxSpacingMs: 3000,
|
||||
maxCrawlDelayMs: 30 * 1000,
|
||||
|
||||
// Backpressure
|
||||
maxQueueLength: 500,
|
||||
maxPendingPerUser: 50,
|
||||
|
||||
// Identity. Stable salt, so a host always sees the same browser across
|
||||
// restarts. Changing this re-rolls every host's assigned profile.
|
||||
profileSalt: 'ss-scrape-v1',
|
||||
robotsUaToken: 'SolidScribeBot',
|
||||
robotsTtlMs: 24 * 60 * 60 * 1000,
|
||||
robotsNegativeTtlMs: 60 * 60 * 1000,
|
||||
robotsErrorTtlMs: 10 * 60 * 1000,
|
||||
robotsMaxOrigins: 2000,
|
||||
|
||||
// Parsing / storage
|
||||
maxSearchTextBytes: 8000,
|
||||
maxKeywordSourceChars: 6000,
|
||||
maxUrlLength: 1000, // matches attachment.url varchar(1000)
|
||||
|
||||
// staticFiles is served from our own origin via /api/static, so an SVG
|
||||
// with an inline script would be same-origin. Off by default.
|
||||
allowSvgThumbnails: false,
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
@@ -2,12 +2,19 @@ module.exports = {
|
||||
apps : [{
|
||||
name: 'NoteServer',
|
||||
script: 'index.js',
|
||||
// Both relative to this file, not to whatever directory pm2 was invoked
|
||||
// from. deploy.sh runs pm2 from the repo root, so without cwd pm2 resolves
|
||||
// script against the root and dies with "Script not found: .../index.js".
|
||||
cwd: __dirname,
|
||||
|
||||
// Options reference: https://pm2.io/doc/en/runtime/reference/ecosystem-file/
|
||||
// args: 'one two',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: true,
|
||||
// Off by default so it is safe in production: with watch on, a `git pull`
|
||||
// that touches server files restarts pm2 mid-pull, potentially booting a
|
||||
// half-updated tree. startLocalDev.sh passes --watch for development.
|
||||
watch: false,
|
||||
ignore_watch : ["node_modules", "staticFiles"],
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
@@ -18,14 +25,4 @@ module.exports = {
|
||||
}
|
||||
}],
|
||||
|
||||
deploy : {
|
||||
production : {
|
||||
user : 'node',
|
||||
host : '212.83.163.1',
|
||||
ref : 'origin/master',
|
||||
repo : '[email protected]:repo.git',
|
||||
path : '/var/www/production',
|
||||
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// Browser identities for outbound fetches.
|
||||
//
|
||||
// The old code sent a single hardcoded Googlebot User-Agent. That was removed
|
||||
// deliberately: Googlebot is verifiable by reverse DNS, so the claim is trivially
|
||||
// disproven, and sites that check it block the source IP outright. Some also
|
||||
// serve crawlers a stripped-down page.
|
||||
//
|
||||
// What replaces it is an honest, *coherent* browser identity. Coherence is the
|
||||
// point - a Chrome User-Agent shipping Firefox's Accept header, or sec-ch-ua on
|
||||
// a Safari request, is a stronger bot signal than any UA string. So each profile
|
||||
// carries the complete header set its family really sends, and nothing mixes.
|
||||
//
|
||||
// Profiles are assigned stably per host, not randomly per request: a site should
|
||||
// see one returning browser, not a rotating cast.
|
||||
//
|
||||
|
||||
const config = require('@config/scrape')
|
||||
|
||||
let BrowserProfiles = module.exports = {}
|
||||
|
||||
const CHROME_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
||||
const FIREFOX_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
|
||||
const SAFARI_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
|
||||
const PROFILES = BrowserProfiles.PROFILES = [
|
||||
{
|
||||
id: 'chrome-win',
|
||||
family: 'chrome',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
||||
accept: CHROME_ACCEPT,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"',
|
||||
secChUaMobile: '?0',
|
||||
secChUaPlatform: '"Windows"',
|
||||
},
|
||||
{
|
||||
id: 'chrome-mac',
|
||||
family: 'chrome',
|
||||
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
||||
accept: CHROME_ACCEPT,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
secChUa: '"Chromium";v="141", "Not;A=Brand";v="24", "Google Chrome";v="141"',
|
||||
secChUaMobile: '?0',
|
||||
secChUaPlatform: '"macOS"',
|
||||
},
|
||||
{
|
||||
id: 'firefox-win',
|
||||
family: 'firefox',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
||||
accept: FIREFOX_ACCEPT,
|
||||
acceptLanguage: 'en-US,en;q=0.5',
|
||||
},
|
||||
{
|
||||
id: 'firefox-linux',
|
||||
family: 'firefox',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
||||
accept: FIREFOX_ACCEPT,
|
||||
acceptLanguage: 'en-US,en;q=0.5',
|
||||
},
|
||||
{
|
||||
id: 'safari-mac',
|
||||
family: 'safari',
|
||||
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15',
|
||||
accept: SAFARI_ACCEPT,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
},
|
||||
]
|
||||
|
||||
// FNV-1a. Not cryptographic - we only need a stable, well-spread bucket.
|
||||
const fnv1a = (input) => {
|
||||
let hash = 0x811c9dc5
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
BrowserProfiles.__fnv1a = fnv1a
|
||||
|
||||
//
|
||||
// Same host always gets the same profile, across requests and across restarts
|
||||
// (the salt is a constant in config, not a per-boot random).
|
||||
//
|
||||
BrowserProfiles.profileForHost = (hostname) => {
|
||||
const key = require('@helpers/UrlSafety').registrableHost(hostname) + config.profileSalt
|
||||
return PROFILES[fnv1a(key) % PROFILES.length]
|
||||
}
|
||||
|
||||
BrowserProfiles.profileById = (id) => PROFILES.find(profile => profile.id === id) || PROFILES[0]
|
||||
|
||||
//
|
||||
// Sec-Fetch-Site is relative: "none" for a URL typed into the bar, "same-origin"
|
||||
// when the referrer shares the origin, "cross-site" otherwise. Getting this
|
||||
// wrong while sending a Referer is exactly the kind of incoherence that flags.
|
||||
//
|
||||
const fetchSite = (targetUrl, referer) => {
|
||||
if (!referer) { return 'none' }
|
||||
try {
|
||||
const target = new URL(targetUrl)
|
||||
const from = new URL(referer)
|
||||
if (target.origin === from.origin) { return 'same-origin' }
|
||||
if (UrlSafetyHost(target) === UrlSafetyHost(from)) { return 'same-site' }
|
||||
return 'cross-site'
|
||||
} catch (error) {
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const UrlSafetyHost = (url) => require('@helpers/UrlSafety').registrableHost(url.hostname)
|
||||
|
||||
const applyChromiumHints = (headers, profile) => {
|
||||
if (profile.family !== 'chrome') { return headers }
|
||||
headers['sec-ch-ua'] = profile.secChUa
|
||||
headers['sec-ch-ua-mobile'] = profile.secChUaMobile
|
||||
headers['sec-ch-ua-platform'] = profile.secChUaPlatform
|
||||
return headers
|
||||
}
|
||||
|
||||
//
|
||||
// Headers for a top-level document navigation.
|
||||
//
|
||||
BrowserProfiles.documentHeaders = (profile, { url, referer } = {}) => {
|
||||
const headers = {
|
||||
'User-Agent': profile.ua,
|
||||
'Accept': profile.accept,
|
||||
'Accept-Language': profile.acceptLanguage,
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
}
|
||||
|
||||
// Safari sends neither sec-ch-ua nor Sec-Fetch-*; Firefox sends Sec-Fetch-*
|
||||
// but no client hints; Chromium sends both.
|
||||
if (profile.family !== 'safari') {
|
||||
headers['Sec-Fetch-Dest'] = 'document'
|
||||
headers['Sec-Fetch-Mode'] = 'navigate'
|
||||
headers['Sec-Fetch-Site'] = fetchSite(url, referer)
|
||||
headers['Sec-Fetch-User'] = '?1'
|
||||
headers['Upgrade-Insecure-Requests'] = '1'
|
||||
}
|
||||
|
||||
applyChromiumHints(headers, profile)
|
||||
|
||||
if (referer) { headers['Referer'] = referer }
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
//
|
||||
// Headers for a subresource image load, as issued from the page it was found on.
|
||||
//
|
||||
BrowserProfiles.imageHeaders = (profile, { url, pageUrl } = {}) => {
|
||||
const headers = {
|
||||
'User-Agent': profile.ua,
|
||||
'Accept': profile.family === 'chrome'
|
||||
? 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
|
||||
: 'image/avif,image/webp,*/*',
|
||||
'Accept-Language': profile.acceptLanguage,
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
}
|
||||
|
||||
if (profile.family !== 'safari') {
|
||||
headers['Sec-Fetch-Dest'] = 'image'
|
||||
headers['Sec-Fetch-Mode'] = 'no-cors'
|
||||
headers['Sec-Fetch-Site'] = fetchSite(url, pageUrl)
|
||||
}
|
||||
|
||||
applyChromiumHints(headers, profile)
|
||||
|
||||
if (pageUrl) { headers['Referer'] = pageUrl }
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
//
|
||||
// Headers for robots.txt. Same identity as the page fetch, but it is not a
|
||||
// navigation - a browser would never send Sec-Fetch-Dest: document for this.
|
||||
//
|
||||
BrowserProfiles.robotsHeaders = (profile) => {
|
||||
const headers = {
|
||||
'User-Agent': profile.ua,
|
||||
'Accept': 'text/plain,*/*;q=0.8',
|
||||
'Accept-Language': profile.acceptLanguage,
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
}
|
||||
|
||||
if (profile.family !== 'safari') {
|
||||
headers['Sec-Fetch-Dest'] = 'empty'
|
||||
headers['Sec-Fetch-Mode'] = 'no-cors'
|
||||
headers['Sec-Fetch-Site'] = 'none'
|
||||
}
|
||||
|
||||
applyChromiumHints(headers, profile)
|
||||
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// Per-host politeness scheduler. Knows nothing about scraping.
|
||||
//
|
||||
// This is the piece that stops the scraper looking like a crawler. The old code
|
||||
// did foundUrls.forEach(fetch) - a note with 40 links opened 40 simultaneous
|
||||
// connections, which is the most bot-like traffic shape available. Here, each
|
||||
// host gets one request at a time with jittered spacing between them, and a
|
||||
// global cap bounds total concurrency.
|
||||
//
|
||||
|
||||
const config = require('@config/scrape')
|
||||
|
||||
let HostQueue = module.exports = {}
|
||||
|
||||
const jitter = (min, max) => Math.round(min + Math.random() * Math.max(0, max - min))
|
||||
|
||||
class Queue {
|
||||
constructor(options = {}) {
|
||||
this.globalConcurrency = options.globalConcurrency != null
|
||||
? options.globalConcurrency : config.maxConcurrentFetches
|
||||
this.perHostConcurrency = options.perHostConcurrency != null
|
||||
? options.perHostConcurrency : config.perHostConcurrency
|
||||
this.minSpacingMs = options.minSpacingMs != null
|
||||
? options.minSpacingMs : config.perHostMinSpacingMs
|
||||
this.maxSpacingMs = options.maxSpacingMs != null
|
||||
? options.maxSpacingMs : config.perHostMaxSpacingMs
|
||||
this.now = options.now || (() => Date.now())
|
||||
// Injectable so tests with a controlled clock don't spend real
|
||||
// wall-time waiting out politeness spacing.
|
||||
this.setTimeoutFn = options.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms))
|
||||
this.clearTimeoutFn = options.clearTimeoutFn || ((handle) => clearTimeout(handle))
|
||||
|
||||
this.pending = [] // FIFO
|
||||
this.hosts = new Map() // hostKey -> { running, nextAllowedAt }
|
||||
this.running = 0
|
||||
this.seq = 0
|
||||
this.timer = null
|
||||
this.stopped = false
|
||||
this.drainWaiters = []
|
||||
}
|
||||
|
||||
hostState(hostKey) {
|
||||
if (!this.hosts.has(hostKey)) {
|
||||
this.hosts.set(hostKey, { running: 0, nextAllowedAt: 0 })
|
||||
}
|
||||
return this.hosts.get(hostKey)
|
||||
}
|
||||
|
||||
//
|
||||
// Queue a task for a host. Resolves/rejects with the task's own outcome.
|
||||
// spacingMs overrides the jitter floor (robots.txt Crawl-delay).
|
||||
//
|
||||
schedule(hostKey, taskFn, options = {}) {
|
||||
if (this.stopped) {
|
||||
return Promise.reject(new Error('HostQueue stopped'))
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.push({
|
||||
hostKey: hostKey || '(none)',
|
||||
taskFn,
|
||||
resolve,
|
||||
reject,
|
||||
spacingMs: options.spacingMs != null ? options.spacingMs : null,
|
||||
seq: this.seq++,
|
||||
})
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// Run whatever is eligible. FIFO scan rather than per-host round-robin, so
|
||||
// no host can starve another and order stays predictable.
|
||||
//
|
||||
pump() {
|
||||
if (this.stopped) { return }
|
||||
|
||||
this.clearTimer()
|
||||
|
||||
while (this.running < this.globalConcurrency) {
|
||||
const at = this.now()
|
||||
let index = -1
|
||||
let earliest = Infinity
|
||||
|
||||
for (let i = 0; i < this.pending.length; i++) {
|
||||
const job = this.pending[i]
|
||||
const host = this.hostState(job.hostKey)
|
||||
|
||||
if (host.running >= this.perHostConcurrency) { continue }
|
||||
|
||||
if (host.nextAllowedAt > at) {
|
||||
earliest = Math.min(earliest, host.nextAllowedAt)
|
||||
continue
|
||||
}
|
||||
|
||||
index = i
|
||||
break
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
// Nothing runnable now. Wake up when the earliest host is due.
|
||||
if (this.pending.length && earliest !== Infinity) {
|
||||
this.armTimer(earliest - at)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
this.run(this.pending.splice(index, 1)[0])
|
||||
}
|
||||
|
||||
this.checkDrained()
|
||||
}
|
||||
|
||||
run(job) {
|
||||
const host = this.hostState(job.hostKey)
|
||||
host.running++
|
||||
this.running++
|
||||
|
||||
const settle = () => {
|
||||
host.running--
|
||||
this.running--
|
||||
|
||||
// Space the NEXT request to this host from the end of this one.
|
||||
const spacing = job.spacingMs != null
|
||||
? job.spacingMs
|
||||
: jitter(this.minSpacingMs, this.maxSpacingMs)
|
||||
host.nextAllowedAt = this.now() + spacing
|
||||
|
||||
this.pump()
|
||||
}
|
||||
|
||||
// A throwing taskFn must not wedge the pump, so the call itself is
|
||||
// wrapped rather than only its promise.
|
||||
let result = null
|
||||
try {
|
||||
result = Promise.resolve(job.taskFn())
|
||||
} catch (error) {
|
||||
result = Promise.reject(error)
|
||||
}
|
||||
|
||||
result.then(
|
||||
value => { settle(); job.resolve(value) },
|
||||
error => { settle(); job.reject(error) }
|
||||
)
|
||||
}
|
||||
|
||||
armTimer(delay) {
|
||||
this.clearTimer()
|
||||
this.timer = this.setTimeoutFn(() => {
|
||||
this.timer = null
|
||||
this.pump()
|
||||
}, Math.max(1, delay))
|
||||
|
||||
//
|
||||
// Deliberately NOT unref'd.
|
||||
//
|
||||
// This timer is the only thing that will resume a host which is inside
|
||||
// its politeness gap. unref'ing it means that if nothing else holds the
|
||||
// event loop open, Node exits and every queued job is silently
|
||||
// abandoned with its promise never settling. Inside the server that is
|
||||
// masked by the listening socket, but it makes the queue unusable from
|
||||
// a standalone script and is a footgun either way.
|
||||
//
|
||||
// Shutdown is handled explicitly instead: stop() clears this timer and
|
||||
// rejects whatever is still queued, and it is wired to SIGINT/SIGTERM
|
||||
// in server/index.js.
|
||||
//
|
||||
}
|
||||
|
||||
clearTimer() {
|
||||
if (this.timer) {
|
||||
this.clearTimeoutFn(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
size() { return this.pending.length }
|
||||
inflight() { return this.running }
|
||||
|
||||
stats() {
|
||||
return {
|
||||
pending: this.pending.length,
|
||||
running: this.running,
|
||||
hosts: this.hosts.size,
|
||||
}
|
||||
}
|
||||
|
||||
checkDrained() {
|
||||
if (this.pending.length || this.running) { return }
|
||||
|
||||
const waiters = this.drainWaiters
|
||||
this.drainWaiters = []
|
||||
waiters.forEach(resolve => resolve())
|
||||
}
|
||||
|
||||
// Resolves once the queue is empty and nothing is in flight.
|
||||
drain() {
|
||||
if (!this.pending.length && !this.running) { return Promise.resolve() }
|
||||
return new Promise(resolve => this.drainWaiters.push(resolve))
|
||||
}
|
||||
|
||||
//
|
||||
// Reject everything still queued. In-flight tasks are not cancelled here -
|
||||
// their own AbortControllers handle that.
|
||||
//
|
||||
stop() {
|
||||
this.stopped = true
|
||||
this.clearTimer()
|
||||
|
||||
const pending = this.pending
|
||||
this.pending = []
|
||||
pending.forEach(job => job.reject(new Error('HostQueue stopped')))
|
||||
|
||||
this.checkDrained()
|
||||
}
|
||||
}
|
||||
|
||||
HostQueue.Queue = Queue
|
||||
|
||||
// Shared instance used by the scrape worker. Tests build their own.
|
||||
HostQueue.shared = new Queue()
|
||||
@@ -0,0 +1,429 @@
|
||||
//
|
||||
// The only module in the server that opens an outbound connection.
|
||||
//
|
||||
// Everything that made the old scraper unreliable is handled here, in one place:
|
||||
// a single AbortController-based timeout (the old code raced request's socket
|
||||
// timeout against a manual setTimeout that could fire against a null reference
|
||||
// and crash the process), manual redirect following so the SSRF gate runs on
|
||||
// every hop, hard size caps enforced while streaming, a Content-Type gate before
|
||||
// any body is read, real charset decoding, and retries that only fire for
|
||||
// transient failures.
|
||||
//
|
||||
|
||||
const config = require('@config/scrape')
|
||||
const UrlSafety = require('@helpers/UrlSafety')
|
||||
const BrowserProfiles = require('@helpers/BrowserProfiles')
|
||||
|
||||
let HttpFetch = module.exports = {}
|
||||
|
||||
const HTML_TYPES = ['text/html', 'application/xhtml+xml', 'text/plain', 'application/xml', 'text/xml']
|
||||
|
||||
class FetchError extends Error {
|
||||
constructor(kind, message, extra = {}) {
|
||||
super(message)
|
||||
this.name = 'FetchError'
|
||||
this.kind = kind // timeout|http|blocked|too_large|bad_content_type|redirect_loop|too_many_redirects|network|aborted
|
||||
this.status = extra.status || null
|
||||
this.retryAfterMs = extra.retryAfterMs || null
|
||||
this.url = extra.url || null
|
||||
this.reason = extra.reason || null
|
||||
}
|
||||
}
|
||||
HttpFetch.FetchError = FetchError
|
||||
|
||||
// Injectable so tests don't spend real wall-time in backoff. Far simpler than
|
||||
// interleaving fake timers with promises.
|
||||
let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
HttpFetch.__setSleep = (fn) => { sleep = fn }
|
||||
HttpFetch.__resetSleep = () => { sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
|
||||
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 507, 509, 521, 522, 523, 524])
|
||||
|
||||
const isRetryable = (error) => {
|
||||
if (!(error instanceof FetchError)) { return false }
|
||||
if (error.kind === 'network' || error.kind === 'timeout') { return true }
|
||||
if (error.kind === 'http') { return RETRYABLE_STATUS.has(error.status) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Retry-After is either delta-seconds or an HTTP-date.
|
||||
const parseRetryAfter = (value) => {
|
||||
if (!value) { return null }
|
||||
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) { return Math.max(0, seconds * 1000) }
|
||||
|
||||
const when = Date.parse(value)
|
||||
if (Number.isFinite(when)) { return Math.max(0, when - Date.now()) }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const backoffFor = (attempt) => {
|
||||
const base = Math.min(config.backoffMaxMs, config.backoffBaseMs * Math.pow(2, attempt))
|
||||
return Math.round(base * (0.5 + Math.random())) // +/-50% jitter
|
||||
}
|
||||
|
||||
const contentTypeOf = (response) => String(response.headers.get('content-type') || '').toLowerCase()
|
||||
const mimeOf = (contentType) => contentType.split(';')[0].trim()
|
||||
|
||||
const charsetFromContentType = (contentType) => {
|
||||
const match = contentType.match(/charset=\s*"?([^";\s]+)"?/i)
|
||||
return match ? match[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
// Last resort: sniff a <meta charset> out of the head, read as latin1 so every
|
||||
// byte maps to a character and the regex can't miss.
|
||||
const charsetFromMeta = (buffer) => {
|
||||
const head = buffer.subarray(0, 2048).toString('latin1')
|
||||
|
||||
const metaCharset = head.match(/<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_\-:]+)/i)
|
||||
if (metaCharset) { return metaCharset[1].toLowerCase() }
|
||||
|
||||
const httpEquiv = head.match(/<meta[^>]+content\s*=\s*["'][^"']*charset=\s*([a-z0-9_\-:]+)/i)
|
||||
if (httpEquiv) { return httpEquiv[1].toLowerCase() }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const charsetFromBom = (buffer) => {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { return 'utf-8' }
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return 'utf-16le' }
|
||||
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { return 'utf-16be' }
|
||||
return null
|
||||
}
|
||||
|
||||
const decodeBody = (buffer, declaredCharset) => {
|
||||
const label = declaredCharset || charsetFromBom(buffer) || charsetFromMeta(buffer) || 'utf-8'
|
||||
|
||||
try {
|
||||
return { text: new TextDecoder(label, { fatal: false }).decode(buffer), charset: label }
|
||||
} catch (error) {
|
||||
// Unknown/bogus label - utf-8 with replacement chars beats throwing.
|
||||
return { text: new TextDecoder('utf-8', { fatal: false }).decode(buffer), charset: 'utf-8' }
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Read the body with a running byte count, aborting mid-stream once the cap is
|
||||
// passed. Content-Length is checked by the caller too, but it is optional and
|
||||
// can lie, so this is the cap that actually holds.
|
||||
//
|
||||
const readCapped = async (response, maxBytes, controller, url) => {
|
||||
if (!response.body) { return Buffer.alloc(0) }
|
||||
|
||||
const chunks = []
|
||||
let total = 0
|
||||
|
||||
for await (const chunk of response.body) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
total += buffer.length
|
||||
|
||||
if (total > maxBytes) {
|
||||
try { controller.abort() } catch (error) { /* already done */ }
|
||||
throw new FetchError('too_large', `Body exceeded ${maxBytes} bytes`, { url })
|
||||
}
|
||||
|
||||
chunks.push(buffer)
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
//
|
||||
// One HTTP request, following redirects by hand.
|
||||
//
|
||||
// Manual redirects are the whole point: fetch's own 'follow' would hop to
|
||||
// 127.0.0.1 without ever re-consulting the safety gate.
|
||||
//
|
||||
const requestOnce = async (startUrl, options) => {
|
||||
const {
|
||||
maxBytes,
|
||||
acceptMimes,
|
||||
headersFor,
|
||||
conditional,
|
||||
timeoutMs,
|
||||
deadline,
|
||||
referer,
|
||||
} = options
|
||||
|
||||
let currentUrl = typeof startUrl === 'string' ? startUrl : startUrl.href
|
||||
const visited = new Set()
|
||||
|
||||
for (let hop = 0; hop <= config.maxRedirects; hop++) {
|
||||
if (visited.has(currentUrl)) {
|
||||
throw new FetchError('redirect_loop', `Redirect loop at ${currentUrl}`, { url: currentUrl })
|
||||
}
|
||||
visited.add(currentUrl)
|
||||
|
||||
// Re-validate on every hop. This is the check the old code never had.
|
||||
try {
|
||||
await UrlSafety.assertFetchable(currentUrl)
|
||||
} catch (error) {
|
||||
// A DNS failure is transient - the host may simply be down right
|
||||
// now - so it must not be classified alongside "this address is
|
||||
// private", which is a permanent refusal. Collapsing the two meant
|
||||
// a briefly-unresolvable domain got parked for the full cache TTL
|
||||
// and had its attachment row deleted.
|
||||
const kind = error.reason === 'dns_failed' ? 'network' : 'blocked'
|
||||
throw new FetchError(kind, error.message, { url: currentUrl, reason: error.reason })
|
||||
}
|
||||
|
||||
const remaining = deadline - Date.now()
|
||||
if (remaining <= 0) {
|
||||
throw new FetchError('timeout', 'Total budget exhausted', { url: currentUrl })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, remaining))
|
||||
|
||||
let response = null
|
||||
try {
|
||||
const headers = headersFor(currentUrl, referer)
|
||||
|
||||
if (conditional && conditional.etag) { headers['If-None-Match'] = conditional.etag }
|
||||
if (conditional && conditional.lastModified) { headers['If-Modified-Since'] = conditional.lastModified }
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
throw new FetchError('timeout', `Timed out after ${timeoutMs}ms`, { url: currentUrl })
|
||||
}
|
||||
throw new FetchError('network', error.message || 'Network failure', { url: currentUrl })
|
||||
}
|
||||
|
||||
// Redirect?
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get('location')) {
|
||||
clearTimeout(timer)
|
||||
try { if (response.body) { await response.body.cancel() } } catch (error) { /* nothing to drain */ }
|
||||
|
||||
const location = response.headers.get('location')
|
||||
let next = null
|
||||
try {
|
||||
next = new URL(location, currentUrl)
|
||||
} catch (error) {
|
||||
throw new FetchError('network', `Unparseable redirect target ${location}`, { url: currentUrl })
|
||||
}
|
||||
|
||||
// Never follow https -> http. A downgrade mid-chain is either an
|
||||
// attack or a misconfiguration, and is not worth the preview.
|
||||
if (new URL(currentUrl).protocol === 'https:' && next.protocol === 'http:') {
|
||||
throw new FetchError('blocked', `Refusing https->http downgrade to ${next.href}`, { url: currentUrl })
|
||||
}
|
||||
|
||||
currentUrl = next.href
|
||||
continue
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
|
||||
if (response.status === 304) {
|
||||
try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ }
|
||||
return { notModified: true, status: 304, finalUrl: currentUrl, response }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try { if (response.body) { await response.body.cancel() } } catch (error) { /* no body */ }
|
||||
throw new FetchError('http', `HTTP ${response.status} for ${currentUrl}`, {
|
||||
status: response.status,
|
||||
retryAfterMs: parseRetryAfter(response.headers.get('retry-after')),
|
||||
url: currentUrl,
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = contentTypeOf(response)
|
||||
const mime = mimeOf(contentType)
|
||||
|
||||
// Gate on type BEFORE reading a byte. This is what stops a 100MB binary
|
||||
// being handed to cheerio.
|
||||
if (acceptMimes && !acceptMimes(mime)) {
|
||||
try { controller.abort() } catch (error) { /* ignore */ }
|
||||
throw new FetchError('bad_content_type', `Refusing content-type ${mime || '(none)'}`, { url: currentUrl })
|
||||
}
|
||||
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
try { controller.abort() } catch (error) { /* ignore */ }
|
||||
throw new FetchError('too_large', `Declared length ${declaredLength} over cap`, { url: currentUrl })
|
||||
}
|
||||
|
||||
const buffer = await readCapped(response, maxBytes, controller, currentUrl)
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
status: response.status,
|
||||
finalUrl: currentUrl,
|
||||
contentType,
|
||||
mime,
|
||||
buffer,
|
||||
response,
|
||||
}
|
||||
}
|
||||
|
||||
throw new FetchError('too_many_redirects', `More than ${config.maxRedirects} redirects`, { url: currentUrl })
|
||||
}
|
||||
|
||||
//
|
||||
// Retry wrapper. Only transient classes are retried - a 404 is an answer.
|
||||
//
|
||||
const withRetries = async (attemptFn, { retries, deadline }) => {
|
||||
let lastError = null
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await attemptFn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (!isRetryable(error) || attempt === retries) { throw error }
|
||||
|
||||
// A server asking us to come back in an hour is not something to
|
||||
// sit on - surface it so the cache can park the URL instead.
|
||||
if (error.retryAfterMs && error.retryAfterMs > config.maxRetryAfterMs) { throw error }
|
||||
|
||||
const wait = error.retryAfterMs != null
|
||||
? Math.min(error.retryAfterMs, config.maxRetryAfterMs)
|
||||
: backoffFor(attempt)
|
||||
|
||||
if (Date.now() + wait >= deadline) { throw error }
|
||||
|
||||
await sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
//
|
||||
// Fetch an HTML document.
|
||||
//
|
||||
HttpFetch.fetchDocument = async (url, options = {}) => {
|
||||
const timeoutMs = options.timeoutMs || config.pageTimeoutMs
|
||||
const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs)
|
||||
const maxBytes = options.maxBytes || config.maxHtmlBytes
|
||||
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
||||
|
||||
const result = await withRetries(() => requestOnce(url, {
|
||||
maxBytes,
|
||||
acceptMimes: (mime) => HTML_TYPES.includes(mime),
|
||||
headersFor: (target, referer) => BrowserProfiles.documentHeaders(profile, {
|
||||
url: target,
|
||||
// A browser arriving at a page has *some* referer; its own origin is
|
||||
// the least surprising one and pairs with Sec-Fetch-Site: same-origin.
|
||||
referer: referer || originOf(target),
|
||||
}),
|
||||
conditional: options.conditional,
|
||||
timeoutMs,
|
||||
deadline,
|
||||
referer: options.referer,
|
||||
}), { retries: options.retries != null ? options.retries : config.retries, deadline })
|
||||
|
||||
if (result.notModified) {
|
||||
return {
|
||||
notModified: true,
|
||||
status: 304,
|
||||
finalUrl: result.finalUrl,
|
||||
html: null,
|
||||
profileId: profile.id,
|
||||
}
|
||||
}
|
||||
|
||||
const declaredCharset = charsetFromContentType(result.contentType)
|
||||
const { text, charset } = decodeBody(result.buffer, declaredCharset)
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
status: result.status,
|
||||
finalUrl: result.finalUrl,
|
||||
html: text,
|
||||
charset,
|
||||
contentType: result.contentType,
|
||||
bytes: result.buffer.length,
|
||||
etag: result.response.headers.get('etag') || null,
|
||||
lastModified: result.response.headers.get('last-modified') || null,
|
||||
profileId: profile.id,
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Fetch a binary (thumbnail image).
|
||||
//
|
||||
HttpFetch.fetchBinary = async (url, options = {}) => {
|
||||
const timeoutMs = options.timeoutMs || config.imageTimeoutMs
|
||||
const deadline = Date.now() + (options.budgetMs || config.totalBudgetMs)
|
||||
const maxBytes = options.maxBytes || config.maxImageBytes
|
||||
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
||||
|
||||
const acceptMimes = (mime) => {
|
||||
if (!mime.startsWith('image/')) { return false }
|
||||
if (mime === 'image/svg+xml' && !config.allowSvgThumbnails) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await withRetries(() => requestOnce(url, {
|
||||
maxBytes,
|
||||
acceptMimes,
|
||||
headersFor: (target) => BrowserProfiles.imageHeaders(profile, {
|
||||
url: target,
|
||||
pageUrl: options.pageUrl,
|
||||
}),
|
||||
timeoutMs,
|
||||
deadline,
|
||||
}), { retries: options.retries != null ? options.retries : config.retries, deadline })
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
finalUrl: result.finalUrl,
|
||||
contentType: result.contentType,
|
||||
mime: result.mime,
|
||||
buffer: result.buffer,
|
||||
profileId: profile.id,
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Fetch robots.txt. Short leash, no retries - fail-open is handled by the caller.
|
||||
//
|
||||
HttpFetch.fetchText = async (url, options = {}) => {
|
||||
const timeoutMs = options.timeoutMs || 5000
|
||||
const deadline = Date.now() + (options.budgetMs || timeoutMs + 1000)
|
||||
const profile = options.profile || BrowserProfiles.profileForHost(hostnameOf(url))
|
||||
|
||||
const result = await requestOnce(url, {
|
||||
maxBytes: options.maxBytes || config.maxRobotsBytes,
|
||||
acceptMimes: null, // robots.txt is served as every type under the sun
|
||||
headersFor: () => BrowserProfiles.robotsHeaders(profile),
|
||||
timeoutMs,
|
||||
deadline,
|
||||
})
|
||||
|
||||
if (result.notModified) { return { status: 304, text: null, finalUrl: result.finalUrl } }
|
||||
|
||||
const { text } = decodeBody(result.buffer, charsetFromContentType(result.contentType))
|
||||
return { status: result.status, text, finalUrl: result.finalUrl }
|
||||
}
|
||||
|
||||
const hostnameOf = (url) => {
|
||||
try { return new URL(typeof url === 'string' ? url : url.href).hostname }
|
||||
catch (error) { return '' }
|
||||
}
|
||||
|
||||
const originOf = (url) => {
|
||||
try { return new URL(url).origin + '/' }
|
||||
catch (error) { return undefined }
|
||||
}
|
||||
|
||||
//
|
||||
// Deliberately NOT attempted here, so nobody wonders later:
|
||||
// - Header ordering: fetch normalises it; controlling order needs raw sockets.
|
||||
// - HTTP/2: undici's fetch is h1 unless an Agent with allowH2 is supplied.
|
||||
// - TLS/JA3 fingerprinting and proxy rotation: ruled out, we are being polite
|
||||
// rather than evasive.
|
||||
//
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// Socket.IO emit wrapper.
|
||||
//
|
||||
// Replaces the `if(typeof SocketIo != 'undefined')` guards that were scattered
|
||||
// through the models. Those guards did not just avoid a crash - they silently
|
||||
// disabled ALL scraping under Jest, which is why none of this code was ever
|
||||
// testable. Here the no-socket case is a no-op rather than a skipped branch, so
|
||||
// the surrounding logic runs identically in tests and in production.
|
||||
//
|
||||
|
||||
let Realtime = module.exports = {}
|
||||
|
||||
let warned = false
|
||||
|
||||
const server = () => (typeof global.SocketIo !== 'undefined' && global.SocketIo) ? global.SocketIo : null
|
||||
|
||||
Realtime.isEnabled = () => server() !== null
|
||||
|
||||
const emit = (room, event, payload) => {
|
||||
const io = server()
|
||||
|
||||
if (!io) {
|
||||
if (!warned) {
|
||||
warned = true
|
||||
console.log('[realtime] no socket server bound; emits are no-ops')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload === undefined) { io.to(room).emit(event) }
|
||||
else { io.to(room).emit(event, payload) }
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log('[realtime] emit failed', event, error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Realtime.emitToUser = (userId, event, payload) => emit(`user:${userId}`, event, payload)
|
||||
Realtime.emitToNote = (noteRawTextId, event, payload) => emit(`note:${noteRawTextId}`, event, payload)
|
||||
|
||||
Realtime.__resetWarning = () => { warned = false }
|
||||
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// robots.txt fetching, parsing and caching.
|
||||
//
|
||||
// Posture: polite, not evasive. An explicit Disallow is respected (fail-closed);
|
||||
// a robots.txt we cannot retrieve is treated as permissive (fail-open), because
|
||||
// a 404 or a flaky server is not a refusal and these are single-page fetches of
|
||||
// URLs a user explicitly saved.
|
||||
//
|
||||
|
||||
const config = require('@config/scrape')
|
||||
const HttpFetch = require('@helpers/HttpFetch')
|
||||
|
||||
let Robots = module.exports = {}
|
||||
|
||||
// origin -> { rules, expiresAt }
|
||||
const cache = new Map()
|
||||
|
||||
Robots.__resetCache = () => cache.clear()
|
||||
Robots.__cacheSize = () => cache.size
|
||||
|
||||
const ALLOW_ALL = { groups: [], allowAll: true }
|
||||
|
||||
//
|
||||
// Parse robots.txt into user-agent groups.
|
||||
//
|
||||
// Consecutive User-agent lines share one rule block, which is the part naive
|
||||
// parsers get wrong.
|
||||
//
|
||||
Robots.parse = (text) => {
|
||||
if (!text || typeof text !== 'string') { return ALLOW_ALL }
|
||||
|
||||
const groups = []
|
||||
let current = null
|
||||
let expectingAgents = false
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/#.*$/, '').trim()
|
||||
if (!line) { continue }
|
||||
|
||||
const separator = line.indexOf(':')
|
||||
if (separator === -1) { continue }
|
||||
|
||||
const field = line.slice(0, separator).trim().toLowerCase()
|
||||
const value = line.slice(separator + 1).trim()
|
||||
|
||||
if (field === 'user-agent') {
|
||||
// A User-agent line directly after another starts a shared group.
|
||||
if (!current || !expectingAgents) {
|
||||
current = { agents: [], rules: [], crawlDelayMs: null }
|
||||
groups.push(current)
|
||||
}
|
||||
current.agents.push(value.toLowerCase())
|
||||
expectingAgents = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!current) { continue } // directive before any user-agent
|
||||
expectingAgents = false
|
||||
|
||||
if (field === 'disallow' || field === 'allow') {
|
||||
// "Disallow:" with an empty value means allow everything.
|
||||
if (field === 'disallow' && value === '') { continue }
|
||||
current.rules.push({ allow: field === 'allow', path: value })
|
||||
continue
|
||||
}
|
||||
|
||||
if (field === 'crawl-delay') {
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
current.crawlDelayMs = Math.round(seconds * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, allowAll: groups.length === 0 }
|
||||
}
|
||||
|
||||
//
|
||||
// Pick the group whose agent token best matches us. A named match always beats
|
||||
// the "*" wildcard, regardless of file order.
|
||||
//
|
||||
const groupFor = (rules, userAgentToken) => {
|
||||
if (!rules || rules.allowAll) { return null }
|
||||
|
||||
const token = userAgentToken.toLowerCase()
|
||||
let best = null
|
||||
let bestScore = -1
|
||||
|
||||
for (const group of rules.groups) {
|
||||
for (const agent of group.agents) {
|
||||
let score = -1
|
||||
if (agent === '*') { score = 0 }
|
||||
else if (token.includes(agent)) { score = agent.length }
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
//
|
||||
// Match a robots path pattern, supporting the * and $ wildcards.
|
||||
//
|
||||
const matchesPattern = (pattern, path) => {
|
||||
if (pattern === '') { return false }
|
||||
if (!pattern.includes('*') && !pattern.includes('$')) {
|
||||
return path.startsWith(pattern)
|
||||
}
|
||||
|
||||
const anchored = pattern.endsWith('$')
|
||||
const body = anchored ? pattern.slice(0, -1) : pattern
|
||||
|
||||
const expression = body
|
||||
.split('*')
|
||||
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*')
|
||||
|
||||
return new RegExp('^' + expression + (anchored ? '$' : '')).test(path)
|
||||
}
|
||||
|
||||
//
|
||||
// Longest matching rule wins; Allow beats Disallow at equal length.
|
||||
//
|
||||
Robots.isPathAllowed = (rules, path, userAgentToken) => {
|
||||
const group = groupFor(rules, userAgentToken)
|
||||
if (!group) { return true }
|
||||
|
||||
let decision = true
|
||||
let decisionLength = -1
|
||||
|
||||
for (const rule of group.rules) {
|
||||
if (!matchesPattern(rule.path, path)) { continue }
|
||||
|
||||
const length = rule.path.length
|
||||
if (length > decisionLength || (length === decisionLength && rule.allow)) {
|
||||
decision = rule.allow
|
||||
decisionLength = length
|
||||
}
|
||||
}
|
||||
|
||||
return decision
|
||||
}
|
||||
|
||||
const evict = () => {
|
||||
if (cache.size <= config.robotsMaxOrigins) { return }
|
||||
// Map preserves insertion order, so the oldest key is first.
|
||||
const oldest = cache.keys().next().value
|
||||
cache.delete(oldest)
|
||||
}
|
||||
|
||||
const getRules = async (origin) => {
|
||||
const cached = cache.get(origin)
|
||||
if (cached && cached.expiresAt > Date.now()) { return cached.rules }
|
||||
|
||||
let rules = ALLOW_ALL
|
||||
let ttl = config.robotsTtlMs
|
||||
|
||||
try {
|
||||
const result = await HttpFetch.fetchText(`${origin}/robots.txt`, {
|
||||
maxBytes: config.maxRobotsBytes,
|
||||
})
|
||||
rules = Robots.parse(result.text)
|
||||
} catch (error) {
|
||||
// No robots.txt, or we could not reach it. Permissive, but cached for a
|
||||
// short while only so a transient outage does not grant a day's pass.
|
||||
rules = ALLOW_ALL
|
||||
ttl = (error && error.kind === 'http' && error.status === 404)
|
||||
? config.robotsNegativeTtlMs
|
||||
: config.robotsErrorTtlMs
|
||||
}
|
||||
|
||||
cache.set(origin, { rules, expiresAt: Date.now() + ttl })
|
||||
evict()
|
||||
|
||||
return rules
|
||||
}
|
||||
Robots.__getRules = getRules
|
||||
|
||||
const originAndPath = (url) => {
|
||||
const parsed = new URL(typeof url === 'string' ? url : url.href)
|
||||
return { origin: parsed.origin, path: parsed.pathname + parsed.search }
|
||||
}
|
||||
|
||||
//
|
||||
// Is this URL allowed by its host's robots.txt?
|
||||
//
|
||||
Robots.isAllowed = async (url, userAgentToken) => {
|
||||
let parts = null
|
||||
try { parts = originAndPath(url) } catch (error) { return false }
|
||||
|
||||
const rules = await getRules(parts.origin)
|
||||
return Robots.isPathAllowed(rules, parts.path, userAgentToken || config.robotsUaToken)
|
||||
}
|
||||
|
||||
//
|
||||
// Crawl-delay for this host, or null. Capped: a site asking for an hour between
|
||||
// requests gets its job failed rather than parked in the queue forever.
|
||||
//
|
||||
Robots.crawlDelayMs = async (url, userAgentToken) => {
|
||||
let parts = null
|
||||
try { parts = originAndPath(url) } catch (error) { return null }
|
||||
|
||||
const rules = await getRules(parts.origin)
|
||||
const group = groupFor(rules, (userAgentToken || config.robotsUaToken).toLowerCase())
|
||||
|
||||
if (!group || group.crawlDelayMs == null) { return null }
|
||||
return group.crawlDelayMs
|
||||
}
|
||||
|
||||
Robots.exceedsCrawlDelayCap = (delayMs) => {
|
||||
return delayMs != null && delayMs > config.maxCrawlDelayMs
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// Verification of the scraper's schema and privileges. Issues NO DDL.
|
||||
//
|
||||
// This module used to converge the schema at boot with CREATE TABLE / ALTER
|
||||
// TABLE. That worked only because dev connects as root (shell.nix exports
|
||||
// DB_USER=root); on production, which runs as a least-privilege `app` user, it
|
||||
// failed with:
|
||||
//
|
||||
// CREATE command denied to user 'app'@'localhost' for table scrape_cache
|
||||
//
|
||||
// The restricted account is correct - the server ingests untrusted URLs and
|
||||
// should not be able to reshape its own tables. So DDL moved out of the
|
||||
// application entirely: an admin runs ./migrateDatabase.sh once, and this
|
||||
// module only checks that the result is present and usable.
|
||||
//
|
||||
// Note on why the DDL is REMOVED rather than guarded by an existence check:
|
||||
// MariaDB evaluates the CREATE privilege before IF NOT EXISTS, so
|
||||
// `CREATE TABLE IF NOT EXISTS` would keep erroring for a DML-only user on every
|
||||
// boot even once the table exists.
|
||||
//
|
||||
|
||||
const db = require('@config/database')
|
||||
|
||||
let Schema = module.exports = {}
|
||||
|
||||
const REQUIRED_TABLE = 'scrape_cache'
|
||||
const REQUIRED_COLUMNS = ['scrape_state', 'scrape_attempts', 'enqueued_at']
|
||||
const REQUIRED_INDEX = 'attachment_scrape_state'
|
||||
|
||||
//
|
||||
// The FULLTEXT index backing Attachment.textSearch's MATCH..AGAINST.
|
||||
//
|
||||
// Checked by TYPE, not just existence: Prisma's `@@fulltext` is a preview
|
||||
// feature and MariaDB is not among its documented databases, so a future
|
||||
// migration could plausibly emit a plain BTREE index of the same name. That
|
||||
// would make attachment search return nothing at all, with no error anywhere -
|
||||
// so it is worth asserting the type explicitly on every boot.
|
||||
//
|
||||
const REQUIRED_FULLTEXT = { table: 'attachment', index: 'attachment_text_index' }
|
||||
|
||||
// The scraper writes and deletes, not just reads. DELETE is genuinely needed:
|
||||
// terminal failures drop the shell attachment row (ScrapeQueue), and
|
||||
// ScrapeCache.purgeExpired removes expired rows.
|
||||
const REQUIRED_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE']
|
||||
|
||||
// Set by verifyScrapeSchema so the per-request paths can check a flag rather
|
||||
// than querying information_schema on every note save.
|
||||
let ready = false
|
||||
let lastResult = null
|
||||
|
||||
Schema.isScrapeSchemaReady = () => ready
|
||||
Schema.lastVerification = () => lastResult
|
||||
|
||||
// Test seam.
|
||||
Schema.__setReady = (value) => { ready = !!value }
|
||||
Schema.__reset = () => { ready = false; lastResult = null }
|
||||
|
||||
const tableExists = async (table) => {
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1`,
|
||||
[table]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const columnExists = async (table, column) => {
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
|
||||
[table, column]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
//
|
||||
// Does this index exist AND is it actually FULLTEXT?
|
||||
//
|
||||
const fulltextIndexExists = async (table, index) => {
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
|
||||
AND INDEX_TYPE = 'FULLTEXT' LIMIT 1`,
|
||||
[table, index]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const indexExists = async (table, index) => {
|
||||
const [rows] = await db.promise().query(
|
||||
`SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`,
|
||||
[table, index]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
Schema.__tableExists = tableExists
|
||||
Schema.__columnExists = columnExists
|
||||
Schema.__indexExists = indexExists
|
||||
Schema.__fulltextIndexExists = fulltextIndexExists
|
||||
|
||||
//
|
||||
// Parse SHOW GRANTS output into the set of privileges that apply to this
|
||||
// database. Any user may run SHOW GRANTS FOR CURRENT_USER(), so this needs no
|
||||
// privileges of its own.
|
||||
//
|
||||
// Grant lines look like:
|
||||
// GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` WITH GRANT OPTION
|
||||
// GRANT SELECT, INSERT, UPDATE, DELETE ON `application`.* TO `app`@`localhost`
|
||||
// GRANT SELECT ON `application`.`attachment` TO `app`@`localhost`
|
||||
// GRANT USAGE ON *.* TO `app`@`localhost`
|
||||
//
|
||||
const PRIVILEGE_PATTERN = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s/i
|
||||
|
||||
Schema.parseGrants = (lines, database, tables) => {
|
||||
const granted = new Set()
|
||||
|
||||
// Scope strings that cover our tables. Backticks are optional in output.
|
||||
const strip = (value) => value.replace(/`/g, '')
|
||||
const relevantScopes = new Set([
|
||||
'*.*',
|
||||
`${database}.*`,
|
||||
...tables.map(table => `${database}.${table}`),
|
||||
])
|
||||
|
||||
for (const line of lines) {
|
||||
const match = String(line).match(PRIVILEGE_PATTERN)
|
||||
if (!match) { continue }
|
||||
|
||||
const scope = strip(match[2])
|
||||
if (!relevantScopes.has(scope)) { continue }
|
||||
|
||||
// Drop any column-level qualifiers, e.g. "SELECT (id, url)".
|
||||
const privileges = match[1].replace(/\s*\([^)]*\)/g, '')
|
||||
|
||||
for (const privilege of privileges.split(',')) {
|
||||
const name = privilege.trim().toUpperCase()
|
||||
if (!name || name === 'USAGE') { continue }
|
||||
|
||||
if (name === 'ALL PRIVILEGES' || name === 'ALL') {
|
||||
REQUIRED_PRIVILEGES.forEach(required => granted.add(required))
|
||||
continue
|
||||
}
|
||||
|
||||
granted.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
return granted
|
||||
}
|
||||
|
||||
const checkPrivileges = async () => {
|
||||
let lines = []
|
||||
|
||||
try {
|
||||
const [rows] = await db.promise().query('SHOW GRANTS FOR CURRENT_USER()')
|
||||
// Each row is an object with a single, server-named column.
|
||||
lines = rows.map(row => Object.values(row)[0])
|
||||
} catch (error) {
|
||||
// If we cannot read our own grants, do not block on a guess - the
|
||||
// object checks above are the real gate.
|
||||
console.log('[schema] could not read grants, skipping privilege check:', error.message)
|
||||
return { missing: [], database: null, currentUser: null }
|
||||
}
|
||||
|
||||
const [dbRows] = await db.promise().query('SELECT DATABASE() AS name, CURRENT_USER() AS who')
|
||||
const database = dbRows[0] && dbRows[0].name
|
||||
const currentUser = dbRows[0] && dbRows[0].who
|
||||
|
||||
const granted = Schema.parseGrants(lines, database, ['attachment', REQUIRED_TABLE])
|
||||
|
||||
return {
|
||||
missing: REQUIRED_PRIVILEGES.filter(privilege => !granted.has(privilege)),
|
||||
database,
|
||||
currentUser,
|
||||
}
|
||||
}
|
||||
|
||||
// 'app@localhost' -> "'app'@'localhost'", so the remedy is copy-pasteable.
|
||||
const quoteUser = (currentUser) => {
|
||||
if (!currentUser) { return '<app user>' }
|
||||
|
||||
const [name, host] = String(currentUser).split('@')
|
||||
return `'${name}'@'${host || 'localhost'}'`
|
||||
}
|
||||
|
||||
//
|
||||
// Check that everything the scraper needs is present. Never mutates anything.
|
||||
//
|
||||
// Returns { ok, missingObjects, missingPrivileges, remedy }.
|
||||
//
|
||||
Schema.verifyScrapeSchema = async () => {
|
||||
const missingObjects = []
|
||||
|
||||
if (!await tableExists(REQUIRED_TABLE)) {
|
||||
missingObjects.push(`table ${REQUIRED_TABLE}`)
|
||||
}
|
||||
|
||||
for (const column of REQUIRED_COLUMNS) {
|
||||
if (!await columnExists('attachment', column)) {
|
||||
missingObjects.push(`attachment.${column}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!await indexExists('attachment', REQUIRED_INDEX)) {
|
||||
missingObjects.push(`attachment index ${REQUIRED_INDEX}`)
|
||||
}
|
||||
|
||||
if (!await fulltextIndexExists(REQUIRED_FULLTEXT.table, REQUIRED_FULLTEXT.index)) {
|
||||
missingObjects.push(`FULLTEXT index ${REQUIRED_FULLTEXT.table}.${REQUIRED_FULLTEXT.index}`)
|
||||
}
|
||||
|
||||
const privileges = await checkPrivileges()
|
||||
const missingPrivileges = privileges.missing || []
|
||||
|
||||
const remedy = []
|
||||
if (missingObjects.length) {
|
||||
remedy.push('run ./migrateDatabase.sh as a database admin')
|
||||
}
|
||||
if (missingPrivileges.length) {
|
||||
// Name the real database and user so this can be pasted straight into
|
||||
// a mysql prompt rather than decoded first.
|
||||
const target = `${privileges.database || '<database>'}.*`
|
||||
remedy.push(
|
||||
`GRANT ${missingPrivileges.join(', ')} ON ${target} TO ${quoteUser(privileges.currentUser)}`
|
||||
)
|
||||
}
|
||||
|
||||
lastResult = {
|
||||
ok: missingObjects.length === 0 && missingPrivileges.length === 0,
|
||||
missingObjects,
|
||||
missingPrivileges,
|
||||
remedy: remedy.join('; '),
|
||||
}
|
||||
ready = lastResult.ok
|
||||
|
||||
return lastResult
|
||||
}
|
||||
@@ -1,26 +1,421 @@
|
||||
let SiteScrape = module.exports = {}
|
||||
|
||||
//
|
||||
// Pure parsing of an already-fetched page. No network I/O, no logging.
|
||||
//
|
||||
// $ = the cheerio scrape object
|
||||
//
|
||||
// Everything here is a pure function of ($, url), which is what makes it
|
||||
// testable against saved HTML fixtures instead of the live internet.
|
||||
//
|
||||
|
||||
const config = require('@config/scrape')
|
||||
|
||||
let SiteScrape = module.exports = {}
|
||||
|
||||
const removeWhitespace = /\s+/g
|
||||
|
||||
const commonWords = ['just','start','what','these','how', 'was', 'being','can','way','share','facebook','twitter','reddit','be','have','do','say','get','make','go','know','take','see','come','think','look','want',
|
||||
'give','use','find','tell','ask','work','seem','feel','try','leave','call','good','new','first','last','long','great','little','own','other','old',
|
||||
'right','big','high','different','small','large','next','early','young','important','few','public','bad','same','able','to','of','in','for','on',
|
||||
'with','at','by','from','up','about','into','over','after','the','and','a','that','I','it','not','he','as','you','this','but','his','they','her',
|
||||
'she','or','an','will','my','one','all','would','there','their','and','that','but','or','as','if','when','than','because','while','where','after',
|
||||
'so','though','since','until','whether','before','although','nor','like','once','unless','now','except','are','also','is','your','its']
|
||||
|
||||
SiteScrape.getTitle = ($) => {
|
||||
|
||||
let title = $('title').text().replace(removeWhitespace, " ")
|
||||
return title
|
||||
'give','use','find','tell','ask','work','seem','feel','try','leave','call','good','new','first','last','long','great','little','own','other','old',
|
||||
'right','big','high','different','small','large','next','early','young','important','few','public','bad','same','able','to','of','in','for','on',
|
||||
'with','at','by','from','up','about','into','over','after','the','and','a','that','I','it','not','he','as','you','this','but','his','they','her',
|
||||
'she','or','an','will','my','one','all','would','there','their','and','that','but','or','as','if','when','than','because','while','where','after',
|
||||
'so','though','since','until','whether','before','although','nor','like','once','unless','now','except','are','also','is','your','its']
|
||||
|
||||
const clean = (value, maxLength) => {
|
||||
if (value == null) { return '' }
|
||||
const text = String(value).replace(removeWhitespace, ' ').trim()
|
||||
return maxLength ? text.substring(0, maxLength) : text
|
||||
}
|
||||
|
||||
//Finds all urls in text, removes duplicates, makes sure they have https://
|
||||
// First non-empty wins. Each candidate is a thunk so a missing branch costs
|
||||
// nothing and nothing throws on the way to the answer.
|
||||
const firstOf = (candidates) => {
|
||||
for (const candidate of candidates) {
|
||||
let value = ''
|
||||
try { value = clean(typeof candidate === 'function' ? candidate() : candidate) }
|
||||
catch (error) { value = '' }
|
||||
if (value) { return value }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const metaContent = ($, selector) => {
|
||||
const node = $(selector).first()
|
||||
return node.length ? node.attr('content') : ''
|
||||
}
|
||||
|
||||
//
|
||||
// JSON-LD. Malformed JSON-LD is extremely common, so every parse is guarded
|
||||
// and a bad block is skipped rather than failing the whole page.
|
||||
//
|
||||
const LD_TYPES = ['Article', 'NewsArticle', 'BlogPosting', 'WebPage', 'Product', 'VideoObject', 'Recipe']
|
||||
|
||||
SiteScrape.getJsonLd = ($) => {
|
||||
const blocks = []
|
||||
|
||||
$('script[type="application/ld+json"]').each((index, element) => {
|
||||
const raw = $(element).contents().text()
|
||||
if (!raw || !raw.trim()) { return }
|
||||
|
||||
let parsed = null
|
||||
try { parsed = JSON.parse(raw) } catch (error) { return }
|
||||
|
||||
// A block may be an object, an array, or wrap its payload in @graph.
|
||||
const flatten = (node) => {
|
||||
if (!node || typeof node !== 'object') { return }
|
||||
if (Array.isArray(node)) { return node.forEach(flatten) }
|
||||
if (Array.isArray(node['@graph'])) { node['@graph'].forEach(flatten) }
|
||||
blocks.push(node)
|
||||
}
|
||||
flatten(parsed)
|
||||
})
|
||||
|
||||
if (!blocks.length) { return null }
|
||||
|
||||
// Prefer a block whose @type looks like page content.
|
||||
const typed = blocks.find(block => {
|
||||
const type = block['@type']
|
||||
const types = Array.isArray(type) ? type : [type]
|
||||
return types.some(entry => LD_TYPES.includes(entry))
|
||||
})
|
||||
|
||||
return typed || blocks[0]
|
||||
}
|
||||
|
||||
const jsonLdImage = (block) => {
|
||||
if (!block || !block.image) { return '' }
|
||||
|
||||
const image = Array.isArray(block.image) ? block.image[0] : block.image
|
||||
if (!image) { return '' }
|
||||
if (typeof image === 'string') { return image }
|
||||
if (typeof image === 'object') { return image.url || image.contentUrl || '' }
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
//
|
||||
// Page title.
|
||||
//
|
||||
SiteScrape.getTitle = ($) => {
|
||||
const ld = SiteScrape.getJsonLd($)
|
||||
|
||||
return firstOf([
|
||||
() => metaContent($, 'meta[property="og:title"]'),
|
||||
() => metaContent($, 'meta[name="twitter:title"]'),
|
||||
() => ld && (ld.headline || ld.name),
|
||||
() => $('title').first().text(),
|
||||
() => $('h1').first().text(),
|
||||
]).substring(0, 500)
|
||||
}
|
||||
|
||||
//
|
||||
// Page description. The old parser extracted none at all.
|
||||
//
|
||||
SiteScrape.getDescription = ($) => {
|
||||
const ld = SiteScrape.getJsonLd($)
|
||||
|
||||
return firstOf([
|
||||
() => metaContent($, 'meta[property="og:description"]'),
|
||||
() => metaContent($, 'meta[name="twitter:description"]'),
|
||||
() => metaContent($, 'meta[name="description"]'),
|
||||
() => ld && ld.description,
|
||||
]).substring(0, 1000)
|
||||
}
|
||||
|
||||
SiteScrape.getSiteName = ($, url) => {
|
||||
return firstOf([
|
||||
() => metaContent($, 'meta[property="og:site_name"]'),
|
||||
() => metaContent($, 'meta[name="application-name"]'),
|
||||
() => {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') }
|
||||
catch (error) { return '' }
|
||||
},
|
||||
]).substring(0, 255)
|
||||
}
|
||||
|
||||
//
|
||||
// Canonical URL. Used as the base for resolving relative asset paths, which is
|
||||
// what the old manual hostname-concatenation got wrong.
|
||||
//
|
||||
SiteScrape.getCanonicalUrl = ($, base) => {
|
||||
const candidate = firstOf([
|
||||
() => $('link[rel="canonical"]').first().attr('href'),
|
||||
() => metaContent($, 'meta[property="og:url"]'),
|
||||
])
|
||||
|
||||
if (!candidate) { return base || '' }
|
||||
|
||||
try { return new URL(candidate, base || undefined).href }
|
||||
catch (error) { return base || '' }
|
||||
}
|
||||
|
||||
//Site hostname with https:// eg: https://www.google.com
|
||||
SiteScrape.getHostName = (url) => {
|
||||
try {
|
||||
return 'https://' + (new URL(url)).hostname
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Image selection.
|
||||
//
|
||||
// Extension allowlist plus an allow-if-extensionless rule. The old substring
|
||||
// filter required .jpg/.jpeg/.png/.gif anywhere in the string, which silently
|
||||
// dropped .webp, .avif, .ico and - more importantly - every extensionless
|
||||
// og:image endpoint like /og?title=x, which is what most modern sites serve.
|
||||
//
|
||||
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.ico', '.bmp', '.jfif']
|
||||
const TRACKING_PIXEL = /(^|[\/_\-.])(1x1|pixel|spacer|blank|transparent|dot)\.(gif|png)/i
|
||||
|
||||
const acceptableImagePath = (href) => {
|
||||
let pathname = ''
|
||||
try { pathname = new URL(href).pathname.toLowerCase() }
|
||||
catch (error) { return false }
|
||||
|
||||
const lastSegment = pathname.split('/').pop() || ''
|
||||
const dot = lastSegment.lastIndexOf('.')
|
||||
|
||||
// No extension at all - allow it. An og:image endpoint is the common case.
|
||||
if (dot === -1) { return true }
|
||||
|
||||
const extension = lastSegment.slice(dot)
|
||||
|
||||
if (extension === '.svg') { return !!config.allowSvgThumbnails }
|
||||
|
||||
return IMAGE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
// Resolve against the page's canonical base. Handles protocol-relative
|
||||
// (//host/x), root-relative, and path-relative uniformly - no string surgery.
|
||||
const resolveImage = (src, base) => {
|
||||
if (!src) { return '' }
|
||||
|
||||
const value = String(src).trim()
|
||||
if (!value || value.startsWith('data:')) { return '' }
|
||||
|
||||
try { return new URL(value, base).href }
|
||||
catch (error) { return '' }
|
||||
}
|
||||
|
||||
// Largest candidate in a srcset, by width descriptor.
|
||||
const largestFromSrcset = (srcset) => {
|
||||
if (!srcset) { return '' }
|
||||
|
||||
let best = ''
|
||||
let bestWidth = -1
|
||||
|
||||
for (const entry of String(srcset).split(',')) {
|
||||
const parts = entry.trim().split(/\s+/)
|
||||
if (!parts[0]) { continue }
|
||||
|
||||
const descriptor = parts[1] || ''
|
||||
const width = descriptor.endsWith('w') ? parseInt(descriptor, 10) : 0
|
||||
|
||||
if (width > bestWidth) {
|
||||
bestWidth = width
|
||||
best = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
const iconCandidates = ($) => {
|
||||
const icons = []
|
||||
|
||||
// apple-touch-icon is usually the largest and cleanest of the icons.
|
||||
$('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').each((index, element) => {
|
||||
const sizes = parseInt($(element).attr('sizes') || '0', 10) || 0
|
||||
icons.push({ href: $(element).attr('href'), weight: 1000 + sizes })
|
||||
})
|
||||
|
||||
$('link[rel="icon"], link[rel="shortcut icon"]').each((index, element) => {
|
||||
const sizes = parseInt($(element).attr('sizes') || '0', 10) || 0
|
||||
icons.push({ href: $(element).attr('href'), weight: sizes })
|
||||
})
|
||||
|
||||
return icons
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.map(icon => icon.href)
|
||||
}
|
||||
|
||||
const inlineImageCandidates = ($) => {
|
||||
const images = []
|
||||
|
||||
$('img').each((index, element) => {
|
||||
const node = $(element)
|
||||
|
||||
// Guarded: the old code read element.attribs.src directly and threw on
|
||||
// an <img> with no attributes.
|
||||
const src = node.attr('src') || node.attr('data-src') || largestFromSrcset(node.attr('srcset'))
|
||||
if (!src) { return }
|
||||
|
||||
const width = parseInt(node.attr('width') || '0', 10) || 0
|
||||
const height = parseInt(node.attr('height') || '0', 10) || 0
|
||||
|
||||
// Tracking pixels and spacers.
|
||||
if ((width > 0 && width <= 1) || (height > 0 && height <= 1)) { return }
|
||||
if (TRACKING_PIXEL.test(src)) { return }
|
||||
|
||||
images.push({ src, area: width * height, order: index })
|
||||
})
|
||||
|
||||
// Biggest first; ties keep document order, so the top of the page wins.
|
||||
return images
|
||||
.sort((a, b) => (b.area - a.area) || (a.order - b.order))
|
||||
.map(image => image.src)
|
||||
}
|
||||
|
||||
//
|
||||
// Ordered candidate list, so "which image" is an explicit priority rather than
|
||||
// the old reverse-iterate-and-overwrite loop that only worked by accident.
|
||||
//
|
||||
SiteScrape.getImageCandidates = ($, url) => {
|
||||
const base = SiteScrape.getCanonicalUrl($, url) || url
|
||||
const ld = SiteScrape.getJsonLd($)
|
||||
|
||||
const raw = [
|
||||
metaContent($, 'meta[property="og:image:secure_url"]'),
|
||||
metaContent($, 'meta[property="og:image:url"]'),
|
||||
metaContent($, 'meta[property="og:image"]'),
|
||||
metaContent($, 'meta[name="twitter:image"]'),
|
||||
metaContent($, 'meta[name="twitter:image:src"]'),
|
||||
jsonLdImage(ld),
|
||||
...inlineImageCandidates($),
|
||||
...iconCandidates($),
|
||||
]
|
||||
|
||||
const seen = new Set()
|
||||
const candidates = []
|
||||
|
||||
for (const entry of raw) {
|
||||
const resolved = resolveImage(entry, base)
|
||||
if (!resolved || seen.has(resolved)) { continue }
|
||||
if (!acceptableImagePath(resolved)) { continue }
|
||||
|
||||
seen.add(resolved)
|
||||
candidates.push(resolved)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// URL for image that can be downloaded to represent website
|
||||
SiteScrape.getDisplayImage = ($, url) => {
|
||||
const candidates = SiteScrape.getImageCandidates($, url)
|
||||
return candidates.length ? candidates[0] : ''
|
||||
}
|
||||
|
||||
// Get all the site text and parse out the words that appear most
|
||||
SiteScrape.getKeywords = ($) => {
|
||||
|
||||
// Strip chrome and scripts before counting, otherwise navigation labels and
|
||||
// inline JS identifiers dominate the frequency table.
|
||||
const working = $.root().clone()
|
||||
working.find('script, style, noscript, nav, header, footer, aside, form, svg').remove()
|
||||
|
||||
let source = working.find('article, main, [role=main], [class*=content]').text()
|
||||
if (!source || source.trim().length < 200) {
|
||||
source = working.find('body').text() || working.text()
|
||||
}
|
||||
|
||||
const majorContent = String(source)
|
||||
.replace(removeWhitespace, ' ')
|
||||
.substring(0, config.maxKeywordSourceChars)
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-z0-9- ]/g, '')
|
||||
|
||||
//Count frequency of each word in scraped text
|
||||
let frequency = {}
|
||||
majorContent.split(' ').forEach(word => {
|
||||
// Exclude short or common words
|
||||
if(commonWords.includes(word) || word.length <= 2){
|
||||
return
|
||||
}
|
||||
if(!frequency[word]){
|
||||
frequency[word] = 0
|
||||
}
|
||||
// Skip some plurals
|
||||
if(frequency[word+'s'] || frequency[word+'es']){
|
||||
return
|
||||
}
|
||||
frequency[word]++
|
||||
})
|
||||
|
||||
//Create a sortable array
|
||||
var sortable = [];
|
||||
for (var index in frequency) {
|
||||
if(frequency[index] > 1){
|
||||
sortable.push([index, frequency[index]]);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort them by most used words in the list
|
||||
sortable.sort(function(a, b) {
|
||||
return b[1] - a[1];
|
||||
});
|
||||
|
||||
let finalWords = []
|
||||
for(let i=0; i<6; i++){
|
||||
if(sortable[i] && sortable[i][0]){
|
||||
finalWords.push(sortable[i][0])
|
||||
}
|
||||
}
|
||||
|
||||
if(finalWords.length > 0){
|
||||
return 'Keywords: ' + finalWords.join(', ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
//
|
||||
// The plaintext blob stored in attachment.text and indexed by the MySQL
|
||||
// FULLTEXT index. Must stay unencrypted for MATCH ... AGAINST to work.
|
||||
//
|
||||
SiteScrape.buildSearchText = ({ title, description, siteName, keywords }) => {
|
||||
const parts = []
|
||||
|
||||
if (title) { parts.push(title) }
|
||||
if (description) { parts.push(description) }
|
||||
if (siteName) { parts.push(siteName) }
|
||||
if (keywords) { parts.push(keywords) }
|
||||
|
||||
return parts.join('\n').substring(0, config.maxSearchTextBytes)
|
||||
}
|
||||
|
||||
//
|
||||
// One call, everything a link preview needs.
|
||||
//
|
||||
SiteScrape.parse = ($, url) => {
|
||||
const canonicalUrl = SiteScrape.getCanonicalUrl($, url)
|
||||
const title = SiteScrape.getTitle($)
|
||||
const description = SiteScrape.getDescription($)
|
||||
const siteName = SiteScrape.getSiteName($, url)
|
||||
const keywords = SiteScrape.getKeywords($)
|
||||
const imageCandidates = SiteScrape.getImageCandidates($, url)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
siteName,
|
||||
canonicalUrl,
|
||||
keywords,
|
||||
imageUrl: imageCandidates.length ? imageCandidates[0] : '',
|
||||
imageCandidates,
|
||||
hostname: SiteScrape.getHostName(url),
|
||||
searchText: SiteScrape.buildSearchText({ title, description, siteName, keywords }),
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Finds all urls in text, removes duplicates, makes sure they have https://
|
||||
//
|
||||
// DELIBERATELY UNCHANGED. This decides which URLs are detected in note text and
|
||||
// what string lands in attachment.url, so altering it would change link
|
||||
// behaviour for every existing note - explicitly out of scope for the scraper
|
||||
// rewrite. The @TODO below is left in place for the same reason.
|
||||
//
|
||||
SiteScrape.getCleanUrls = (textBlock) => {
|
||||
//Find all URLs in text
|
||||
//@TODO - Use the process text library for this function
|
||||
@@ -49,171 +444,3 @@ SiteScrape.getCleanUrls = (textBlock) => {
|
||||
|
||||
return foundUrls
|
||||
}
|
||||
|
||||
//Site hostname with https:// eg: https://www.google.com
|
||||
SiteScrape.getHostName = (url) => {
|
||||
|
||||
var hostname = 'https://'+(new URL(url)).hostname;
|
||||
// console.log('hostname', hostname)
|
||||
return hostname
|
||||
}
|
||||
|
||||
// URL for image that can be downloaded to represent website
|
||||
SiteScrape.getDisplayImage = ($, url) => {
|
||||
|
||||
const hostname = SiteScrape.getHostName(url)
|
||||
|
||||
let metaImg = $('[property="og:image"]')
|
||||
let shortcutIcon = $('[rel="shortcut icon"]')
|
||||
let favicon = $('[rel="icon"]')
|
||||
let randomImg = $('img')
|
||||
|
||||
//Set of images we may want gathered from various places in source
|
||||
let imagesWeWant = []
|
||||
let thumbnail = ''
|
||||
|
||||
//Scrape metadata for page image
|
||||
if(randomImg && randomImg.length > 0){
|
||||
|
||||
let imgSrcs = []
|
||||
for (let i = 0; i < randomImg.length; i++) {
|
||||
imgSrcs.push( randomImg[i].attribs.src )
|
||||
}
|
||||
|
||||
const half = Math.ceil(imgSrcs.length / 2)
|
||||
imagesWeWant = [...imgSrcs.slice(-half), ...imgSrcs.slice(0,half) ]
|
||||
|
||||
}
|
||||
//Grab the shortcut icon
|
||||
if(favicon && favicon[0] && favicon[0].attribs){
|
||||
imagesWeWant.push(favicon[0].attribs.href)
|
||||
}
|
||||
//Grab the shortcut icon
|
||||
if(shortcutIcon && shortcutIcon[0] && shortcutIcon[0].attribs){
|
||||
imagesWeWant.push(shortcutIcon[0].attribs.href)
|
||||
}
|
||||
//Grab the presentation image for the site
|
||||
if(metaImg && metaImg[0] && metaImg[0].attribs){
|
||||
imagesWeWant.unshift(metaImg[0].attribs.content)
|
||||
}
|
||||
|
||||
// console.log(imagesWeWant)
|
||||
|
||||
//Remove everything that isn't an accepted file format
|
||||
for (let i = imagesWeWant.length - 1; i >= 0; i--) {
|
||||
|
||||
let img = String(imagesWeWant[i])
|
||||
|
||||
if(
|
||||
!img.includes('.jpg') &&
|
||||
!img.includes('.jpeg') &&
|
||||
!img.includes('.png') &&
|
||||
!img.includes('.gif')
|
||||
){
|
||||
imagesWeWant.splice(i,1)
|
||||
}
|
||||
}
|
||||
|
||||
//Find if we have absolute thumbnails or not
|
||||
let foundAbsolute = false
|
||||
for (let i = imagesWeWant.length - 1; i >= 0; i--) {
|
||||
|
||||
let img = imagesWeWant[i]
|
||||
|
||||
//Add host name if its not included
|
||||
if(String(img).includes('//') || String(img).includes('http')){
|
||||
foundAbsolute = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
//Go through all found images. Grab the one closest to the top. Closer is better
|
||||
for (let i = imagesWeWant.length - 1; i >= 0; i--) {
|
||||
|
||||
let img = imagesWeWant[i]
|
||||
|
||||
if(!String(img).includes('//') && foundAbsolute){
|
||||
continue;
|
||||
}
|
||||
|
||||
//Only add host to images if no absolute images were found
|
||||
if(!String(img).includes('//') ){
|
||||
if(img.indexOf('/') != 0){
|
||||
img = '/' + img
|
||||
}
|
||||
img = hostname + img
|
||||
}
|
||||
|
||||
if(img.indexOf('//') == 0){
|
||||
img = 'https:' + img //Scrape breaks without protocol
|
||||
}
|
||||
|
||||
thumbnail = img
|
||||
|
||||
}
|
||||
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
// Get all the site text and parse out the words that appear most
|
||||
SiteScrape.getKeywords = ($) => {
|
||||
|
||||
let majorContent = ''
|
||||
|
||||
majorContent += $('[class*=content]').text()
|
||||
.replace(removeWhitespace, " ") //Remove all whitespace
|
||||
// .replace(/\W\s/g, '') //Remove all non alphanumeric characters
|
||||
.substring(0,6000) //Limit to 6000 characters
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-z0-9- ]/g, '');
|
||||
|
||||
|
||||
console.log(majorContent)
|
||||
|
||||
//Count frequency of each word in scraped text
|
||||
let frequency = {}
|
||||
majorContent.split(' ').forEach(word => {
|
||||
// Exclude short or common words
|
||||
if(commonWords.includes(word) || word.length <= 2){
|
||||
return
|
||||
}
|
||||
if(!frequency[word]){
|
||||
frequency[word] = 0
|
||||
}
|
||||
// Skip some plurals
|
||||
if(frequency[word+'s'] || frequency[word+'es']){
|
||||
return
|
||||
}
|
||||
frequency[word]++
|
||||
})
|
||||
|
||||
//Create a sortable array
|
||||
var sortable = [];
|
||||
for (var index in frequency) {
|
||||
if(frequency[index] > 1){
|
||||
sortable.push([index, frequency[index]]);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort them by most used words in the list
|
||||
sortable.sort(function(a, b) {
|
||||
return b[1] - a[1];
|
||||
});
|
||||
|
||||
let finalWords = []
|
||||
for(let i=0; i<6; i++){
|
||||
if(sortable[i] && sortable[i][0]){
|
||||
finalWords.push(sortable[i][0])
|
||||
}
|
||||
}
|
||||
|
||||
if(finalWords.length > 0){
|
||||
return 'Keywords: ' + finalWords.join(', ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
SiteScrape.getMainText = ($) => {}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// URL normalisation and the SSRF gate.
|
||||
//
|
||||
// Scraped URLs come from untrusted note text and from the /pushmebaby query
|
||||
// param, so every URL is validated before a socket is opened - and again after
|
||||
// every redirect hop, which is the hop that actually matters (a public URL that
|
||||
// 302s to 127.0.0.1 is the classic bypass).
|
||||
//
|
||||
// Pure except for assertFetchable, which resolves DNS.
|
||||
//
|
||||
|
||||
const crypto = require('crypto')
|
||||
const dns = require('dns').promises
|
||||
const net = require('net')
|
||||
const config = require('@config/scrape')
|
||||
|
||||
let UrlSafety = module.exports = {}
|
||||
|
||||
const FETCHABLE_SCHEMES = new Set(['http:', 'https:'])
|
||||
|
||||
// Hostname suffixes that never belong to a public site.
|
||||
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa', '.lan']
|
||||
|
||||
// Query params that change nothing about the page but would fragment the cache.
|
||||
const TRACKING_PARAMS = [
|
||||
/^utm_/i, /^fbclid$/i, /^gclid$/i, /^dclid$/i, /^msclkid$/i,
|
||||
/^mc_(e|c)id$/i, /^igshid$/i, /^ref_src$/i, /^_hs(enc|mi)$/i, /^vero_/i,
|
||||
]
|
||||
|
||||
class SafetyError extends Error {
|
||||
constructor(reason, message) {
|
||||
super(message || reason)
|
||||
this.name = 'SafetyError'
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
UrlSafety.SafetyError = SafetyError
|
||||
|
||||
//
|
||||
// Parse to a URL object, or null. Does NOT mutate anything that would change
|
||||
// which resource is addressed - callers may store the result.
|
||||
//
|
||||
UrlSafety.parse = (raw, base) => {
|
||||
if (!raw || typeof raw !== 'string') { return null }
|
||||
|
||||
let url = null
|
||||
try {
|
||||
url = new URL(raw.trim(), base || undefined)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!FETCHABLE_SCHEMES.has(url.protocol)) { return null }
|
||||
if (url.href.length > config.maxUrlLength) { return null }
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
//
|
||||
// Canonical form used ONLY for cache keys. Never store this - it deliberately
|
||||
// discards tracking params and case, which would change what the user wrote.
|
||||
//
|
||||
UrlSafety.normalizeForKey = (raw) => {
|
||||
const url = UrlSafety.parse(raw)
|
||||
if (!url) { return null }
|
||||
|
||||
url.hash = ''
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
|
||||
// Trailing dot on the host is legal DNS but splits the cache key.
|
||||
if (url.hostname.endsWith('.')) {
|
||||
url.hostname = url.hostname.slice(0, -1)
|
||||
}
|
||||
url.hostname = url.hostname.toLowerCase()
|
||||
|
||||
// new URL already strips the default port, but be explicit.
|
||||
if ((url.protocol === 'http:' && url.port === '80') ||
|
||||
(url.protocol === 'https:' && url.port === '443')) {
|
||||
url.port = ''
|
||||
}
|
||||
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (TRACKING_PARAMS.some(pattern => pattern.test(key))) {
|
||||
url.searchParams.delete(key)
|
||||
}
|
||||
}
|
||||
url.searchParams.sort()
|
||||
|
||||
if (url.pathname === '') { url.pathname = '/' }
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// sha256 of the normalised href. This is scrape_cache.url_hash - char(64),
|
||||
// because a UNIQUE index on varchar(1000) utf8mb4 would be 4000 bytes and
|
||||
// blow InnoDB's 3072-byte key limit.
|
||||
UrlSafety.urlKey = (raw) => {
|
||||
const url = UrlSafety.normalizeForKey(raw)
|
||||
if (!url) { return null }
|
||||
return crypto.createHash('sha256').update(url.href).digest('hex')
|
||||
}
|
||||
|
||||
//
|
||||
// True when two URLs address the same resource modulo scheme, "www." and a
|
||||
// trailing slash.
|
||||
//
|
||||
// Load-bearing: scanTextForWebsites diffs the URLs currently in a note against
|
||||
// the URLs already stored as attachments. Raw string equality would treat
|
||||
// http://foo.com and https://foo.com as different resources and delete-then-
|
||||
// rescrape the row on every save.
|
||||
//
|
||||
UrlSafety.sameResource = (a, b) => {
|
||||
const left = UrlSafety.normalizeForKey(a)
|
||||
const right = UrlSafety.normalizeForKey(b)
|
||||
if (!left || !right) { return false }
|
||||
|
||||
const host = (url) => url.hostname.replace(/^www\./, '')
|
||||
const path = (url) => url.pathname.replace(/\/+$/, '')
|
||||
|
||||
return host(left) === host(right) &&
|
||||
path(left) === path(right) &&
|
||||
left.search === right.search
|
||||
}
|
||||
|
||||
UrlSafety.registrableHost = (hostname) => {
|
||||
return String(hostname || '').toLowerCase().replace(/^www\./, '').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
//
|
||||
// Private / non-routable address detection.
|
||||
//
|
||||
// Covers the ranges that matter for SSRF against this app: loopback, RFC1918,
|
||||
// link-local (including the 169.254.169.254 cloud metadata endpoint), CGNAT,
|
||||
// and the IPv6 equivalents.
|
||||
//
|
||||
UrlSafety.isPrivateIp = (address) => {
|
||||
if (!address) { return true }
|
||||
|
||||
const version = net.isIP(address)
|
||||
if (version === 0) { return true } // not an IP - caller resolves first
|
||||
|
||||
if (version === 4) { return isPrivateIpv4(address) }
|
||||
return isPrivateIpv6(address)
|
||||
}
|
||||
|
||||
const isPrivateIpv4 = (address) => {
|
||||
const parts = address.split('.').map(Number)
|
||||
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||
return true
|
||||
}
|
||||
const [a, b] = parts
|
||||
|
||||
if (a === 0) { return true } // 0.0.0.0/8 "this host"
|
||||
if (a === 10) { return true } // RFC1918
|
||||
if (a === 127) { return true } // loopback
|
||||
if (a === 169 && b === 254) { return true } // link-local + metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) { return true } // RFC1918
|
||||
if (a === 192 && b === 168) { return true } // RFC1918
|
||||
if (a === 192 && b === 0) { return true } // 192.0.0/24 + 192.0.2/24
|
||||
if (a === 198 && (b === 18 || b === 19)) { return true } // benchmarking
|
||||
if (a === 100 && b >= 64 && b <= 127) { return true } // CGNAT
|
||||
if (a === 198 && b === 51) { return true } // TEST-NET-2
|
||||
if (a === 203 && b === 0) { return true } // TEST-NET-3
|
||||
if (a >= 224) { return true } // multicast + reserved
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isPrivateIpv6 = (address) => {
|
||||
const lower = address.toLowerCase().replace(/^\[|\]$/g, '')
|
||||
|
||||
// Unwrap v4-mapped (::ffff:127.0.0.1) and NAT64 (64:ff9b::/96) before
|
||||
// judging, otherwise ::ffff:127.0.0.1 reads as "some v6 address".
|
||||
const mapped = lower.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)
|
||||
if (mapped) { return isPrivateIpv4(mapped[1]) }
|
||||
|
||||
if (lower === '::' || lower === '::1') { return true } // unspecified, loopback
|
||||
if (/^f[cd]/.test(lower)) { return true } // fc00::/7 unique-local
|
||||
if (/^fe[89ab]/.test(lower)) { return true } // fe80::/10 link-local
|
||||
if (/^ff/.test(lower)) { return true } // multicast
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
//
|
||||
// The gate. Throws SafetyError; resolves to the vetted addresses on success.
|
||||
//
|
||||
// Known limitation (documented, accepted): this resolves DNS and then hands the
|
||||
// hostname to fetch, which resolves again. A hostile 0-TTL record can answer
|
||||
// differently the second time. Closing that properly needs a custom undici
|
||||
// Agent with connect.lookup - deliberately deferred, see the plan's Phase 5.
|
||||
// Residual risk is blind SSRF only: no credentials are sent and no response
|
||||
// body is returned to whoever planted the URL.
|
||||
//
|
||||
UrlSafety.assertFetchable = async (raw) => {
|
||||
const url = raw instanceof URL ? raw : UrlSafety.parse(raw)
|
||||
|
||||
if (!url) {
|
||||
throw new SafetyError('bad_url', `Not a fetchable URL: ${raw}`)
|
||||
}
|
||||
if (!FETCHABLE_SCHEMES.has(url.protocol)) {
|
||||
throw new SafetyError('bad_scheme', `Refusing scheme ${url.protocol}`)
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new SafetyError('credentials_in_url', 'Refusing URL with embedded credentials')
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
|
||||
|
||||
if (!hostname) {
|
||||
throw new SafetyError('bad_url', 'URL has no host')
|
||||
}
|
||||
if (hostname === 'localhost' || BLOCKED_SUFFIXES.some(suffix => hostname.endsWith(suffix))) {
|
||||
throw new SafetyError('blocked_host', `Refusing internal host ${hostname}`)
|
||||
}
|
||||
|
||||
// Literal IP in the URL - no DNS needed.
|
||||
if (net.isIP(hostname)) {
|
||||
if (UrlSafety.isPrivateIp(hostname)) {
|
||||
throw new SafetyError('private_ip', `Refusing non-routable address ${hostname}`)
|
||||
}
|
||||
return [hostname]
|
||||
}
|
||||
|
||||
let resolved = []
|
||||
try {
|
||||
resolved = await dns.lookup(hostname, { all: true, verbatim: true })
|
||||
} catch (error) {
|
||||
throw new SafetyError('dns_failed', `Could not resolve ${hostname}: ${error.code || error.message}`)
|
||||
}
|
||||
|
||||
if (!resolved.length) {
|
||||
throw new SafetyError('dns_failed', `No addresses for ${hostname}`)
|
||||
}
|
||||
|
||||
// Every answer must be public. One private address is enough to refuse -
|
||||
// otherwise a round-robin record gets us some of the time.
|
||||
for (const entry of resolved) {
|
||||
if (UrlSafety.isPrivateIp(entry.address)) {
|
||||
throw new SafetyError('private_ip', `${hostname} resolves to non-routable ${entry.address}`)
|
||||
}
|
||||
}
|
||||
|
||||
return resolved.map(entry => entry.address)
|
||||
}
|
||||
@@ -8,11 +8,17 @@ const ports = {
|
||||
}
|
||||
|
||||
//Allow user of @ in in require calls. Config in package.json
|
||||
require('module-alias/register')
|
||||
// Point module-alias at the repo root explicitly. The bare /register entry
|
||||
// locates package.json by walking up from its own directory, which lands
|
||||
// inside .pnpm/ under pnpm's symlinked store and fails to find it.
|
||||
require('module-alias')(require('path').join(__dirname, '..'))
|
||||
|
||||
//Auth helper, used for decoding users web token
|
||||
let Auth = require('@helpers/Auth')
|
||||
|
||||
//Note model, used to authorise socket room joins
|
||||
let Note = require('@models/Note')
|
||||
|
||||
//Helmet adds additional security to express server
|
||||
const helmet = require('helmet')
|
||||
|
||||
@@ -48,6 +54,14 @@ global.SocketIo = io
|
||||
|
||||
let noteDiffs = {}
|
||||
|
||||
//
|
||||
// Room names are namespaced. Previously note rooms were keyed on the bare
|
||||
// note_raw_text.id and user rooms on the bare user.id, sharing one flat
|
||||
// keyspace -- so user 7 and note_raw_text 7 were the same room.
|
||||
//
|
||||
const userRoom = userId => `user:${userId}`
|
||||
const noteRoom = rawTextId => `note:${rawTextId}`
|
||||
|
||||
io.on('connection', function(socket){
|
||||
|
||||
//When a user connects, add them to their own room
|
||||
@@ -56,11 +70,11 @@ io.on('connection', function(socket){
|
||||
socket.on('user_connect', token => {
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
socket.join(userData.userId)
|
||||
socket.join(userRoom(userData.userId))
|
||||
|
||||
//Track active logged in user accounts
|
||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
||||
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||
|
||||
}).catch(error => {
|
||||
//Don't add user to room if they are not logged in
|
||||
@@ -71,11 +85,11 @@ io.on('connection', function(socket){
|
||||
socket.on('get_active_user_count', token => {
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
socket.join(userData.userId)
|
||||
socket.join(userRoom(userData.userId))
|
||||
|
||||
//Track active logged in user accounts
|
||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
||||
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||
|
||||
}).catch(error => {
|
||||
// console.log(error)
|
||||
@@ -104,67 +118,106 @@ io.on('connection', function(socket){
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('join_room', rawTextId => {
|
||||
// Join user to rawtextid room when they enter
|
||||
socket.join(rawTextId)
|
||||
//
|
||||
// The client asks for a NOTE, not a room. The server authorises the request
|
||||
// against the session and derives the room itself, so a client can no longer
|
||||
// name an arbitrary room and be handed its buffered diffs.
|
||||
//
|
||||
socket.on('join_room', ({ noteId, token } = {}) => {
|
||||
|
||||
//If there are past diffs for this note, send them to the user
|
||||
if(noteDiffs[rawTextId] != undefined){
|
||||
|
||||
//Sort all note diffs by when they were created.
|
||||
noteDiffs[rawTextId].sort((a,b) => { return a.time - b.time })
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
|
||||
//Emit all sorted diffs to user
|
||||
socket.emit('past_diffs', noteDiffs[rawTextId])
|
||||
}
|
||||
return Note.getRawTextIdForUser(noteId, userData.userId)
|
||||
.then(rawTextId => {
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[rawTextId]
|
||||
if(usersInRoom){
|
||||
//Update users in room count
|
||||
io.to(rawTextId).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
})
|
||||
// No claim on this note -> no room, no diffs
|
||||
if(rawTextId == null){ return }
|
||||
|
||||
socket.on('leave_room', roomId => {
|
||||
socket.leave(roomId)
|
||||
// console.log('User Left room')
|
||||
const room = noteRoom(rawTextId)
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[roomId]
|
||||
if(usersInRoom){
|
||||
// console.log('Users in room', usersInRoom.length)
|
||||
io.to(roomId).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
|
||||
})
|
||||
// Remember what this socket is allowed into, so leave_room and
|
||||
// note_diff never have to trust a client-supplied room again
|
||||
socket.authorizedRooms = socket.authorizedRooms || {}
|
||||
socket.authorizedRooms[noteId] = { room, rawTextId }
|
||||
|
||||
socket.on('note_diff', data => {
|
||||
socket.join(room)
|
||||
|
||||
//Log each diff for note
|
||||
const noteId = data.id
|
||||
delete data.id
|
||||
if(noteDiffs[noteId] == undefined){ noteDiffs[noteId] = [] }
|
||||
data.time = +new Date
|
||||
|
||||
noteDiffs[noteId].push(data)
|
||||
//If there are past diffs for this note, send them to the user
|
||||
if(noteDiffs[rawTextId] != undefined){
|
||||
|
||||
// Go over each user in this note-room
|
||||
io.in(noteId).clients((error, clients) => {
|
||||
if (error) throw error;
|
||||
//Sort all note diffs by when they were created.
|
||||
noteDiffs[rawTextId].sort((a,b) => { return a.time - b.time })
|
||||
|
||||
//Go through each client in note-room and send them the diff
|
||||
clients.forEach(socketId => {
|
||||
// only send off diff if user
|
||||
if(socketId != socket.id){
|
||||
io.to(socketId).emit('incoming_diff', data)
|
||||
//Emit all sorted diffs to user
|
||||
socket.emit('past_diffs', noteDiffs[rawTextId])
|
||||
}
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[room]
|
||||
if(usersInRoom){
|
||||
//Update users in room count
|
||||
io.to(room).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
//Not logged in, or no access. Join nothing.
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('truncate_diffs_at_save', checkpoint => {
|
||||
socket.on('leave_room', ({ noteId } = {}) => {
|
||||
|
||||
// Only rooms this socket was actually authorised into
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[noteId]
|
||||
if(!entry){ return }
|
||||
|
||||
socket.leave(entry.room)
|
||||
delete socket.authorizedRooms[noteId]
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[entry.room]
|
||||
if(usersInRoom){
|
||||
io.to(entry.room).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('note_diff', (data = {}) => {
|
||||
|
||||
// Room comes from what this socket was authorised into, never from the
|
||||
// payload
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[data.noteId]
|
||||
if(!entry){ return }
|
||||
|
||||
const room = entry.room
|
||||
const rawTextId = entry.rawTextId
|
||||
|
||||
//Log each diff for note
|
||||
if(noteDiffs[rawTextId] == undefined){ noteDiffs[rawTextId] = [] }
|
||||
|
||||
// Carry the raw-text id through. This used to `delete data.id`, stripping
|
||||
// the only identifier before storing and broadcasting -- so a client with
|
||||
// several notes open on one socket could not tell which note a patch
|
||||
// belonged to, and applied every one to whichever editor it found first.
|
||||
const payload = Object.assign({}, data, { rawTextId, time: +new Date })
|
||||
delete payload.noteId
|
||||
|
||||
noteDiffs[rawTextId].push(payload)
|
||||
|
||||
// Everyone in the note room except the sender
|
||||
socket.to(room).emit('incoming_diff', payload)
|
||||
})
|
||||
|
||||
socket.on('truncate_diffs_at_save', (checkpoint = {}) => {
|
||||
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[checkpoint.noteId]
|
||||
if(!entry){ return }
|
||||
checkpoint.rawTextId = entry.rawTextId
|
||||
|
||||
// NOTE: the comparison below is `diffSet[i].hash == checkpoint`, comparing
|
||||
// a hash string against the whole checkpoint object, so it never matches
|
||||
// and sliceTo stays 0 -- meaning the entire diff set is discarded at every
|
||||
// save. That happens to be the fail-safe outcome (the saved text becomes
|
||||
// the new baseline), and the correct slice direction for a real fix is
|
||||
// ambiguous, so it is deliberately left alone rather than guessed at.
|
||||
let diffSet = noteDiffs[checkpoint.rawTextId]
|
||||
if(diffSet && diffSet.length > 0){
|
||||
|
||||
@@ -292,10 +345,72 @@ app.use('/api/quick-note', quickNote)
|
||||
var metricTracking = require('@routes/metrictrackingController')
|
||||
app.use('/api/metric-tracking', metricTracking)
|
||||
|
||||
//Output running status
|
||||
app.listen(ports.express, () => {
|
||||
console.log(`Express: Listening on port ${ports.express}!`)
|
||||
})
|
||||
//
|
||||
// Scraper startup.
|
||||
//
|
||||
// The application deliberately holds no DDL privileges, so it does not create
|
||||
// its own schema - verifyScrapeSchema() only checks that an admin has already
|
||||
// run ./migrateDatabase.sh and that the required DML grants are present.
|
||||
//
|
||||
// When anything is missing the scraper stays off rather than failing an INSERT
|
||||
// on every note save. The listener binds either way: the API must not depend on
|
||||
// link previews being available.
|
||||
//
|
||||
const Schema = require('@helpers/Schema')
|
||||
const ScrapeQueue = require('@workers/ScrapeQueue')
|
||||
|
||||
Schema.verifyScrapeSchema()
|
||||
.then(result => {
|
||||
if (!result.ok) {
|
||||
const missing = [
|
||||
...result.missingObjects,
|
||||
...result.missingPrivileges.map(privilege => `privilege ${privilege}`),
|
||||
].join(', ')
|
||||
|
||||
console.log(`Scraper: DISABLED - missing ${missing}. To fix: ${result.remedy}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Say so positively. Previously a healthy scraper logged nothing, so
|
||||
// "started" and "silently disabled" looked identical in pm2 logs --
|
||||
// which is how a dead scraper went unnoticed on production.
|
||||
console.log('Scraper: ready')
|
||||
|
||||
ScrapeQueue.start()
|
||||
|
||||
// The queue lives in memory, so a pm2 reload loses whatever was in it.
|
||||
// Every enqueued URL already has an attachment row, which makes the
|
||||
// work re-derivable without any extra table.
|
||||
return ScrapeQueue.recoverPending()
|
||||
})
|
||||
.then(recovery => {
|
||||
if (recovery && recovery.requeued) {
|
||||
console.log(`Scraper: re-queued ${recovery.requeued} pending URL(s), retired ${recovery.retired}`)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// A scraper that cannot start must not stop the API from serving.
|
||||
console.log('Scraper: startup check failed, link previews disabled this boot:', error.message)
|
||||
})
|
||||
.finally(() => {
|
||||
//Output running status
|
||||
app.listen(ports.express, () => {
|
||||
console.log(`Express: Listening on port ${ports.express}!`)
|
||||
})
|
||||
})
|
||||
|
||||
//
|
||||
// pm2 reload sends SIGINT. Stop accepting new scrape work and let in-flight
|
||||
// requests abort via their own AbortControllers; rows left mid-fetch are
|
||||
// recovered on the next boot.
|
||||
//
|
||||
const shutdown = (signal) => {
|
||||
console.log(`Received ${signal}, stopping scrape queue`)
|
||||
try { ScrapeQueue.stop() } catch (error) { /* already down */ }
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
|
||||
//
|
||||
//Error handlers
|
||||
|
||||
@@ -6,15 +6,30 @@ const cs = require('@helpers/CryptoString')
|
||||
let Attachment = module.exports = {}
|
||||
|
||||
const cheerio = require('cheerio')
|
||||
const rp = require('request-promise')
|
||||
const request = require('request')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const gm = require('gm')
|
||||
|
||||
const tesseract = require("node-tesseract-ocr")
|
||||
|
||||
// Left as-is deliberately: the upload / OCR / thumbnail code below resolves
|
||||
// against it, and it is correct under pm2 (cwd = server/). Not touching it
|
||||
// keeps the upload path byte-identical.
|
||||
const filePath = '../staticFiles/'
|
||||
|
||||
// Absolute equivalent, used by the scrape path only. filePath is CWD-relative,
|
||||
// so under Jest (cwd = repo root) scrape downloads landed in a sibling of the
|
||||
// repo rather than in staticFiles/. This resolves to the same directory as
|
||||
// filePath under pm2.
|
||||
const scrapeFilePath = path.join(__dirname, '..', '..', 'staticFiles') + path.sep
|
||||
|
||||
const config = require('@config/scrape')
|
||||
const UrlSafety = require('@helpers/UrlSafety')
|
||||
const HttpFetch = require('@helpers/HttpFetch')
|
||||
const Realtime = require('@helpers/Realtime')
|
||||
|
||||
// Attachment.migrateOld
|
||||
|
||||
Attachment.textSearch = (userId, searchTerm) => {
|
||||
@@ -263,264 +278,235 @@ Attachment.generateThumbnail = (fileName) => {
|
||||
})
|
||||
}
|
||||
|
||||
//Scans text for websites, returns all attachments
|
||||
Attachment.scanTextForWebsites = (io, userId, noteId, noteText) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
//
|
||||
// Diff the URLs currently in a note against the attachments already stored for
|
||||
// it, soft-delete the ones that vanished, and enqueue the new ones.
|
||||
//
|
||||
// Returns immediately with the text already known from cache; freshly scraped
|
||||
// text arrives asynchronously via the worker. Nothing here waits on the network.
|
||||
//
|
||||
Attachment.scanTextForWebsites = (userId, noteId, noteText) => {
|
||||
|
||||
let solrAttachmentText = '' //Final searchable scrape text for note
|
||||
const ScrapeQueue = require('@workers/ScrapeQueue')
|
||||
const Schema = require('@helpers/Schema')
|
||||
|
||||
if(noteText.length == 0){ resolve(solrAttachmentText) }
|
||||
// Fixed: the old version called resolve() without returning, so an empty
|
||||
// note fell through and ran the DB lookup anyway.
|
||||
if (!noteText || noteText.length == 0) { return Promise.resolve('') }
|
||||
|
||||
Attachment.urlForNote(userId, noteId).then(attachments => {
|
||||
// When the scrape schema is absent the new columns do not exist, so
|
||||
// creating a pending row would fail on every save. The diff and soft-delete
|
||||
// below still run - they touch no new columns - so removing a link from a
|
||||
// note behaves exactly as before.
|
||||
const scrapeAvailable = Schema.isScrapeSchemaReady()
|
||||
|
||||
//Pull all the URLs out of the text
|
||||
let foundUrls = SiteScrape.getCleanUrls(noteText)
|
||||
let knownText = ''
|
||||
|
||||
return Attachment.urlForNote(userId, noteId)
|
||||
.then(attachments => {
|
||||
|
||||
const foundUrls = SiteScrape.getCleanUrls(noteText)
|
||||
const remaining = foundUrls.slice()
|
||||
|
||||
//Go through each saved URL, remove new URLs from saved URLs
|
||||
//If a URL is not found, delete it
|
||||
attachments.forEach(attachment => {
|
||||
//URL already scraped, push text and continue
|
||||
let urlIndex = foundUrls.indexOf( attachment.url )
|
||||
|
||||
if(urlIndex != -1){
|
||||
solrAttachmentText += attachment.text
|
||||
foundUrls.splice(urlIndex, 1) //Remove existing from set of found
|
||||
// Compared on normalised keys rather than raw strings, so an
|
||||
// http/https or www. variant is not treated as a different
|
||||
// resource and needlessly deleted and re-scraped.
|
||||
const index = remaining.findIndex(url =>
|
||||
url === attachment.url || UrlSafety.sameResource(url, attachment.url))
|
||||
|
||||
if (index !== -1) {
|
||||
knownText += attachment.text || ''
|
||||
remaining.splice(index, 1)
|
||||
} else {
|
||||
//If existing attachment is not found in note, remove it
|
||||
// Unchanged behaviour: link attachments are hidden, not deleted.
|
||||
Attachment.delete(userId, attachment.id, true)
|
||||
}
|
||||
})
|
||||
|
||||
//No newly scraped URLs, resolve with looked up attachment text
|
||||
if(foundUrls == null || foundUrls.length == 0){
|
||||
return resolve(solrAttachmentText)
|
||||
}
|
||||
if (!remaining.length) { return knownText }
|
||||
|
||||
//Process the remaining URLs into attachments
|
||||
Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => {
|
||||
// No schema, no previews. Return what is already cached rather than
|
||||
// attempting inserts that cannot succeed.
|
||||
if (!scrapeAvailable) { return knownText }
|
||||
|
||||
//Once everything is done being scraped, emit new attachment events
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
|
||||
// Tell user to update attachments with scraped text
|
||||
SocketIo.to(userId).emit('update_note_attachments')
|
||||
|
||||
solrAttachmentText += freshlyScrapedText
|
||||
resolve(solrAttachmentText)
|
||||
})
|
||||
.catch(console.log)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
//Return scraped text from each URL
|
||||
Attachment.scrapeUrlsCreateAttachments = (userId, noteId, foundUrls) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if(foundUrls == null || foundUrls.length == 0){
|
||||
return resolve('')
|
||||
}
|
||||
|
||||
console.log('About to scrape')
|
||||
console.log(foundUrls)
|
||||
|
||||
let processedCount = 0
|
||||
let scrapedText = ''
|
||||
|
||||
//Process each URL passd to function, a DB entry will be created for each scrape
|
||||
foundUrls.forEach(url => {
|
||||
Attachment.processUrl(userId, noteId, url).then( freshlyScrapedText => {
|
||||
|
||||
scrapedText += freshlyScrapedText
|
||||
processedCount ++
|
||||
|
||||
//All URLs have been scraped, return data
|
||||
if(processedCount == foundUrls.length){
|
||||
console.log('All urls scraped')
|
||||
return resolve(scrapedText)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Site Scrape error', error)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Attachment.downloadFileFromUrl = (url) => {
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if(!url){
|
||||
return resolve(null)
|
||||
}
|
||||
|
||||
const random = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
||||
let extension = ''
|
||||
let fileName = random+'_scrape'
|
||||
let thumbPath = 'thumb_'+fileName
|
||||
|
||||
console.log('Scraping image url', url)
|
||||
|
||||
console.log('Getting ready to scrape ', url)
|
||||
|
||||
request(url)
|
||||
.on('error', error => {
|
||||
console.log(error)
|
||||
resolve(null)
|
||||
})
|
||||
.on('response', res => {
|
||||
console.log(res.statusCode)
|
||||
console.log(res.headers['content-type'])
|
||||
//Get mime type from header content type
|
||||
// extension = '.'+String(res.headers['content-type']).split('/').pop()
|
||||
})
|
||||
.pipe(fs.createWriteStream(filePath+thumbPath))
|
||||
.on('close', () => {
|
||||
|
||||
//resize image if its real big
|
||||
gm(filePath+thumbPath)
|
||||
.resize(550) //Resize to width of 550 px
|
||||
.quality(85) //compression level 0 - 100 (best)
|
||||
.write(filePath+thumbPath, function (err) {
|
||||
if(err){
|
||||
console.log(err)
|
||||
return resolve(null)
|
||||
}
|
||||
|
||||
console.log('Saved Image')
|
||||
return resolve(fileName)
|
||||
// Create a shell row per new URL, then hand off to the worker.
|
||||
return Promise.all(remaining.map(url =>
|
||||
Attachment.createPendingUrlAttachment(userId, noteId, url)
|
||||
.then(attachmentId => ({ userId, noteId, attachmentId, url }))
|
||||
.catch(error => {
|
||||
console.log('[scrape] could not create attachment row', url, error.message)
|
||||
return null
|
||||
})
|
||||
))
|
||||
.then(jobs => {
|
||||
const valid = jobs.filter(Boolean)
|
||||
|
||||
})
|
||||
// Fire and forget - the note save must not wait on this.
|
||||
ScrapeQueue.enqueueUrls(valid)
|
||||
.catch(error => console.log('[scrape] enqueue failed', error.message))
|
||||
|
||||
return knownText
|
||||
})
|
||||
})
|
||||
// The old version had no catch here at all.
|
||||
.catch(error => {
|
||||
console.log('[scrape] scanTextForWebsites failed', error.message)
|
||||
return knownText
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// Create the shell attachment row for a URL and return its id.
|
||||
//
|
||||
// Returns the id BEFORE any network work begins. The old code armed a 5s
|
||||
// setTimeout calling request.cancel() synchronously, before this INSERT had
|
||||
// resolved - so a slow INSERT meant the timer fired against a null reference
|
||||
// and threw inside a bare timer callback, taking the process down.
|
||||
//
|
||||
Attachment.createPendingUrlAttachment = (userId, noteId, url) => {
|
||||
const created = Math.round((+new Date) / 1000)
|
||||
|
||||
return db.promise()
|
||||
.query(
|
||||
`INSERT INTO attachment
|
||||
(note_id, user_id, attachment_type, text, url, last_indexed, file_location, scrape_state, enqueued_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[noteId, userId, 1, url, url, created, null, 0, created]
|
||||
)
|
||||
.then(([result]) => result.insertId)
|
||||
}
|
||||
|
||||
//
|
||||
// Scrape a set of URLs for a note and resolve with their combined text.
|
||||
//
|
||||
// Kept for the bookmarklet path. The old implementation resolved only when a
|
||||
// completion counter reached foundUrls.length, so a single rejection meant the
|
||||
// promise never settled and the caller hung forever. allSettled inside the
|
||||
// worker removes the counter entirely.
|
||||
//
|
||||
Attachment.scrapeUrlsCreateAttachments = (userId, noteId, foundUrls) => {
|
||||
|
||||
const ScrapeQueue = require('@workers/ScrapeQueue')
|
||||
const Schema = require('@helpers/Schema')
|
||||
|
||||
if (!foundUrls || !foundUrls.length) { return Promise.resolve('') }
|
||||
if (!Schema.isScrapeSchemaReady()) { return Promise.resolve('') }
|
||||
|
||||
return Promise.all(foundUrls.map(url =>
|
||||
Attachment.createPendingUrlAttachment(userId, noteId, url)
|
||||
.then(attachmentId => ({ userId, noteId, attachmentId, url }))
|
||||
.catch(error => {
|
||||
console.log('[scrape] could not create attachment row', url, error.message)
|
||||
return null
|
||||
})
|
||||
))
|
||||
.then(jobs => ScrapeQueue.enqueueUrls(jobs.filter(Boolean)))
|
||||
.then(texts => texts.join(''))
|
||||
}
|
||||
|
||||
//
|
||||
// Download and store a preview image.
|
||||
//
|
||||
// The old downloadFileFromUrl had no timeout, no size limit, no User-Agent and
|
||||
// no status check: it piped whatever came back straight into a write stream, so
|
||||
// a 404 HTML error page was written to disk and handed to gm. It also wrote the
|
||||
// file before validating anything, which is why staticFiles/ accumulated
|
||||
// zero-byte orphans.
|
||||
//
|
||||
Attachment.downloadThumbnail = (imageUrl, pageUrl) => {
|
||||
|
||||
if (!imageUrl) { return Promise.resolve(null) }
|
||||
|
||||
// crypto.randomBytes rather than two concatenated Math.random() calls.
|
||||
const fileName = crypto.randomBytes(16).toString('hex') + '_scrape'
|
||||
const fullPath = scrapeFilePath + fileName
|
||||
const thumbPath = scrapeFilePath + 'thumb_' + fileName
|
||||
const tempPath = fullPath + '.tmp'
|
||||
|
||||
return HttpFetch.fetchBinary(imageUrl, {
|
||||
pageUrl,
|
||||
timeoutMs: config.imageTimeoutMs,
|
||||
maxBytes: config.maxImageBytes,
|
||||
})
|
||||
.then(result => {
|
||||
|
||||
// Status and content-type were both gated inside fetchBinary, so by
|
||||
// here we are holding bytes that really are an image.
|
||||
if (!result.buffer || !result.buffer.length) { return null }
|
||||
if (!magicBytesMatch(result.buffer, result.mime)) {
|
||||
console.log('[scrape] image bytes do not match declared type', result.mime, imageUrl)
|
||||
return null
|
||||
}
|
||||
|
||||
// Write to a temp path, then rename, so a failure cannot leave a
|
||||
// half-written file behind for the client to serve.
|
||||
return fs.promises.writeFile(tempPath, result.buffer)
|
||||
.then(() => fs.promises.rename(tempPath, fullPath))
|
||||
.then(() => resizeToThumbnail(fullPath, thumbPath))
|
||||
.then(() => fileName)
|
||||
.catch(error => {
|
||||
console.log('[scrape] could not store image', imageUrl, error.message)
|
||||
return cleanupFiles([tempPath, fullPath, thumbPath]).then(() => null)
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('[scrape] image fetch failed', imageUrl, error.kind || error.message)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
Attachment.processUrl = (userId, noteId, url) => {
|
||||
//
|
||||
// Resize via gm. Degrades rather than failing: gm (and tesseract) are not in
|
||||
// shell.nix buildInputs, so the binary is absent in dev - the old code returned
|
||||
// null here and left the downloaded file orphaned on disk. Copying the
|
||||
// full-size image to the thumbnail path keeps the preview working.
|
||||
//
|
||||
const resizeToThumbnail = (fullPath, thumbPath) => {
|
||||
return new Promise(resolve => {
|
||||
gm(fullPath)
|
||||
.resize(550) //Resize to width of 550 px
|
||||
.quality(85) //compression level 0 - 100 (best)
|
||||
.write(thumbPath, error => {
|
||||
if (!error) { return resolve(true) }
|
||||
|
||||
const scrapeTime = 5*1000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
|
||||
const options = {
|
||||
uri: url,
|
||||
simple: true,
|
||||
timeout: scrapeTime,
|
||||
headers: {
|
||||
'User-Agent':'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' //Simulate google headers
|
||||
},
|
||||
transform: function (body) {
|
||||
return cheerio.load(body);
|
||||
}
|
||||
}
|
||||
|
||||
let requestTimeout = null
|
||||
let thumbnail = null
|
||||
let request = null
|
||||
let created = Math.round((+new Date)/1000)
|
||||
let insertedId = null
|
||||
|
||||
//Create a shell attachment for each URL, put in processing state
|
||||
db.promise()
|
||||
.query(`INSERT INTO attachment
|
||||
(note_id, user_id, attachment_type, text, url, last_indexed, file_location)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[noteId, userId, 1, url, url, created, null])
|
||||
.then((rows, fields) => {
|
||||
//Set two bigger variables then return request for processing
|
||||
request = rp(options)
|
||||
insertedId = rows[0].insertId
|
||||
|
||||
return request
|
||||
})
|
||||
.then($ => {
|
||||
|
||||
//Clear timeout that would end this function
|
||||
clearTimeout(requestTimeout)
|
||||
|
||||
// let header = $('h1').text().replace(removeWhitespace, " ")
|
||||
// desiredSearchText += header + "\n"
|
||||
|
||||
const pageTitle = SiteScrape.getTitle($)
|
||||
|
||||
const hostname = SiteScrape.getHostName(url)
|
||||
|
||||
const thumbnail = SiteScrape.getDisplayImage($, url)
|
||||
|
||||
const keywords = SiteScrape.getKeywords($)
|
||||
|
||||
var desiredSearchText = ''
|
||||
desiredSearchText += pageTitle
|
||||
if(keywords){
|
||||
desiredSearchText += "\n " + keywords
|
||||
}
|
||||
|
||||
console.log('Results from site scrape-------------')
|
||||
console.log({
|
||||
pageTitle,
|
||||
hostname,
|
||||
thumbnail,
|
||||
keywords
|
||||
console.log('[scrape] gm unavailable or failed, using full size as thumbnail')
|
||||
fs.promises.copyFile(fullPath, thumbPath)
|
||||
.then(() => resolve(true))
|
||||
.catch(() => resolve(false))
|
||||
})
|
||||
|
||||
|
||||
// throw new Error('Ending this function early.')
|
||||
|
||||
|
||||
// console.log('TexT Scraped')
|
||||
// console.log(desiredSearchText)
|
||||
|
||||
created = Math.round((+new Date)/1000)
|
||||
|
||||
//Scrape URL for thumbnail - take filename and save in attachment
|
||||
Attachment.downloadFileFromUrl(thumbnail)
|
||||
.then(thumbnailFilename => {
|
||||
|
||||
//Update text and thumbnail filename
|
||||
created = Math.round((+new Date)/1000)
|
||||
db.promise()
|
||||
.query(`UPDATE attachment SET
|
||||
text = ?,
|
||||
last_indexed = ?,
|
||||
file_location = ?
|
||||
WHERE id = ?
|
||||
`, [desiredSearchText, created, thumbnailFilename, insertedId])
|
||||
.then((rows, fields) => {
|
||||
resolve(desiredSearchText) //Return found text
|
||||
})
|
||||
.catch(console.log)
|
||||
|
||||
|
||||
//Create attachment in DB with scrape text and provided data
|
||||
// db.promise()
|
||||
// .query(`INSERT INTO attachment
|
||||
// (note_id, user_id, attachment_type, text, url, last_indexed, file_location)
|
||||
// VALUES (?, ?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created, thumbnailFilename])
|
||||
// .then((rows, fields) => {
|
||||
|
||||
// resolve(desiredSearchText) //Return found text
|
||||
// })
|
||||
// .catch(console.log)
|
||||
})
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Scrape pooped out')
|
||||
console.log('Issue with scrape', error.statusCode)
|
||||
clearTimeout(requestTimeout)
|
||||
return resolve('No site text')
|
||||
})
|
||||
|
||||
requestTimeout = setTimeout( () => {
|
||||
console.log('Cancel the request, its taking to long.')
|
||||
request.cancel()
|
||||
return resolve('Request Timeout')
|
||||
}, scrapeTime )
|
||||
})
|
||||
}
|
||||
|
||||
const cleanupFiles = (paths) => {
|
||||
return Promise.all(paths.map(target =>
|
||||
fs.promises.unlink(target).catch(() => null)
|
||||
))
|
||||
}
|
||||
|
||||
//
|
||||
// Sanity-check the bytes against the declared content type. A server can claim
|
||||
// image/png and send anything.
|
||||
//
|
||||
const IMAGE_MAGIC = [
|
||||
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47] },
|
||||
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
||||
{ mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||
{ mime: 'image/bmp', bytes: [0x42, 0x4d] },
|
||||
]
|
||||
|
||||
const magicBytesMatch = (buffer, mime) => {
|
||||
const expected = IMAGE_MAGIC.find(entry => entry.mime === mime)
|
||||
|
||||
// webp/avif/ico and friends have container-dependent headers - accept them
|
||||
// on the content-type alone rather than guessing wrong.
|
||||
if (!expected) { return true }
|
||||
if (buffer.length < expected.bytes.length) { return false }
|
||||
|
||||
return expected.bytes.every((byte, index) => buffer[index] === byte)
|
||||
}
|
||||
|
||||
Attachment.generatePushKey = (userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -576,37 +562,60 @@ Attachment.getPushkeyBookmarklet = (userId) => {
|
||||
})
|
||||
}
|
||||
|
||||
Attachment.pushUrl = (pushkey,url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
//
|
||||
// Bookmarklet entry point: GET /api/public/pushmebaby?pushkey=...&url=...
|
||||
//
|
||||
// The URL arrives straight off a query string, so it is validated here - the
|
||||
// old code passed it through completely unvalidated, bypassing even the https
|
||||
// normalisation that note text goes through.
|
||||
//
|
||||
// Response timing is preserved: the bookmarklet still answers after the scrape,
|
||||
// but now with a hard cap instead of the previous unbounded hang.
|
||||
//
|
||||
Attachment.pushUrl = (pushkey, url) => {
|
||||
|
||||
let userId = null
|
||||
pushkey = pushkey.replace(/ /g, '+')
|
||||
const ScrapeQueue = require('@workers/ScrapeQueue')
|
||||
const Schema = require('@helpers/Schema')
|
||||
|
||||
db.promise()
|
||||
// Preserve the bookmarklet contract - resolve truthy, leaking nothing -
|
||||
// rather than failing an insert against columns that do not exist.
|
||||
if (!Schema.isScrapeSchemaReady()) { return Promise.resolve(true) }
|
||||
|
||||
let userId = null
|
||||
pushkey = String(pushkey || '').replace(/ /g, '+')
|
||||
|
||||
return db.promise()
|
||||
.query("SELECT id FROM user WHERE pushkey = ? LIMIT 1", [pushkey])
|
||||
.then((rows, fields) => {
|
||||
.then(([rows]) => {
|
||||
|
||||
if(rows[0].length == 0){
|
||||
return resolve(true)
|
||||
}
|
||||
// Unchanged: an unknown pushkey resolves truthy and leaks nothing
|
||||
// about whether the key exists.
|
||||
if (rows.length == 0) { return null }
|
||||
|
||||
userId = rows[0][0].id
|
||||
return Attachment.scrapeUrlsCreateAttachments(userId, null, [url])
|
||||
userId = rows[0].id
|
||||
|
||||
return UrlSafety.assertFetchable(url)
|
||||
.then(() => Attachment.createPendingUrlAttachment(userId, null, url))
|
||||
.then(attachmentId => ScrapeQueue.awaitUrl(
|
||||
ScrapeQueue.enqueueUrl({ userId, noteId: null, attachmentId, url }),
|
||||
config.totalBudgetMs
|
||||
))
|
||||
.catch(error => {
|
||||
console.log('[scrape] pushUrl refused', url, error.reason || error.message)
|
||||
return null
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
//Once everything is done being scraped, emit new attachment events
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
|
||||
// Tell user to update attachments with scraped text
|
||||
SocketIo.to(userId).emit('update_note_attachments')
|
||||
if (userId) {
|
||||
Realtime.emitToUser(userId, 'update_counts')
|
||||
Realtime.emitToUser(userId, 'update_note_attachments')
|
||||
}
|
||||
|
||||
return resolve(true)
|
||||
return true
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('[scrape] pushUrl failed', error.message)
|
||||
return true
|
||||
})
|
||||
.catch(console.log)
|
||||
})
|
||||
}
|
||||
|
||||
Attachment.generateBookmarkletText = (pushKey) => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
let db = require('@config/database')
|
||||
|
||||
let Note = require('@models/Note')
|
||||
|
||||
let CycleTracking = module.exports = {}
|
||||
|
||||
|
||||
CycleTracking.get = (userId, masterKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
db.promise()
|
||||
.query(`
|
||||
SELECT note.id FROM note WHERE quick_note = 2 AND user_id = ? LIMIT 1`, [userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
//Quick Note is set, return note object
|
||||
if(rows[0][0] != undefined){
|
||||
|
||||
let noteId = rows[0][0].id
|
||||
const note = Note.get(userId, noteId, masterKey)
|
||||
.then(noteData => {
|
||||
return resolve(noteData)
|
||||
})
|
||||
|
||||
} else {
|
||||
//Or create a new note
|
||||
let finalId = null
|
||||
return Note.create(userId, 'Cycle Tracking', '', masterKey)
|
||||
.then(insertedId => {
|
||||
finalId = insertedId
|
||||
db.promise().query('UPDATE note SET quick_note = 2 WHERE id = ? AND user_id = ?',[insertedId, userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
const note = Note.get(userId, finalId, masterKey)
|
||||
.then(noteData => {
|
||||
return resolve(noteData)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
.catch(console.log)
|
||||
})
|
||||
}
|
||||
|
||||
CycleTracking.save = (userId, cycleData, masterKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
let finalId = null
|
||||
|
||||
CycleTracking.get(userId, masterKey)
|
||||
.then(noteObject => {
|
||||
|
||||
return Note.update(userId, noteObject.id, cycleData, noteObject.title, noteObject.color, noteObject.pinned, noteObject.archived, null, masterKey)
|
||||
|
||||
})
|
||||
.then( saveResults => {
|
||||
return resolve(saveResults)
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ let Note = module.exports = {}
|
||||
|
||||
let Tags = require('@models/Tag')
|
||||
let Attachment = require('@models/Attachment')
|
||||
let Realtime = require('@helpers/Realtime')
|
||||
let ShareNote = require('@models/ShareNote')
|
||||
|
||||
let ProcessText = require('@helpers/ProcessText')
|
||||
@@ -12,7 +13,6 @@ const DiffMatchPatch = require('@helpers/DiffMatchPatch')
|
||||
|
||||
const crypto = require('crypto')
|
||||
const cs = require('@helpers/CryptoString')
|
||||
const rp = require('request-promise');
|
||||
const fs = require('fs')
|
||||
const gm = require('gm')
|
||||
|
||||
@@ -199,7 +199,7 @@ Note.create = (userId, noteTitle = '', noteText = '', masterKey) => {
|
||||
.then((rows, fields) => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
SocketIo.to(userId).emit('new_note_created', rows[0].insertId)
|
||||
SocketIo.to(`user:${userId}`).emit('new_note_created', rows[0].insertId)
|
||||
}
|
||||
|
||||
// Indexing is done on save
|
||||
@@ -490,7 +490,7 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
||||
}
|
||||
db.promise().query('UPDATE note SET snippet = ? WHERE id = ?', [updatedSnippet, otherNote.id])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(otherNote['user_id']).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
||||
SocketIo.to(`user:${otherNote['user_id']}`).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -513,12 +513,15 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
||||
})
|
||||
.then((rows, fields) => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId, hash})
|
||||
// The old `if(typeof SocketIo != 'undefined')` guard did not just
|
||||
// skip the emit - it skipped the scrape too, so link scraping was
|
||||
// silently disabled under Jest and could never be tested. Realtime
|
||||
// no-ops safely without a socket server, so both now always run.
|
||||
Realtime.emitToUser(userId, 'new_note_text_saved', {noteId, hash})
|
||||
|
||||
//Async attachment reindex
|
||||
Attachment.scanTextForWebsites(SocketIo, userId, noteId, noteText)
|
||||
}
|
||||
//Async attachment reindex
|
||||
Attachment.scanTextForWebsites(userId, noteId, noteText)
|
||||
.catch(error => console.log('scanTextForWebsites failed', error.message))
|
||||
|
||||
//Send back updated response
|
||||
resolve(rows[0])
|
||||
@@ -538,7 +541,7 @@ Note.setPinned = (userId, noteId, pinnedBoolean) => {
|
||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET pinned = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||
[pinned, now, noteId, userId])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -555,7 +558,7 @@ Note.setArchived = (userId, noteId, archivedBoolead) => {
|
||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET archived = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||
[archived, now, noteId, userId])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -581,7 +584,7 @@ Note.setTrashed = (userId, noteId, trashedBoolean, masterKey) => {
|
||||
Note.reindex(userId, masterKey, removeFromIndex)
|
||||
|
||||
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -653,7 +656,7 @@ Note.delete = (userId, noteId, masterKey = null) => {
|
||||
}
|
||||
})
|
||||
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||
|
||||
if(masterKey){
|
||||
//Remove note ID from index
|
||||
@@ -1190,4 +1193,30 @@ Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => {
|
||||
.catch(console.log)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Resolve a note's raw-text id for a given user.
|
||||
// Doubles as the access check: a user with no claim on this note gets null,
|
||||
// so authorisation and room derivation are the same lookup.
|
||||
//
|
||||
Note.getRawTextIdForUser = (noteId, userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const id = parseInt(noteId, 10)
|
||||
if(!Number.isInteger(id) || id < 1){ return resolve(null) }
|
||||
|
||||
db.promise()
|
||||
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ? LIMIT 1', [id, userId])
|
||||
.then((rows) => {
|
||||
|
||||
if(!rows[0] || rows[0].length == 0){ return resolve(null) }
|
||||
|
||||
return resolve( rows[0][0]['note_raw_text_id'] )
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
return resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//
|
||||
// Read-through cache for scraped URL metadata, keyed by a hash of the
|
||||
// normalised URL and shared across users and notes.
|
||||
//
|
||||
// A fresh hit means zero network I/O. A stale hit means a conditional request
|
||||
// (If-None-Match / If-Modified-Since), so an unchanged page costs one 304
|
||||
// rather than a full page load. Failures are backed off so a dead link is not
|
||||
// refetched on every note save.
|
||||
//
|
||||
// Stores only public page metadata. Note content never reaches this table.
|
||||
//
|
||||
|
||||
const db = require('@config/database')
|
||||
const UrlSafety = require('@helpers/UrlSafety')
|
||||
|
||||
let ScrapeCache = module.exports = {}
|
||||
|
||||
const SUCCESS_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
// Widening backoff on repeated failure, then give up and stay failed.
|
||||
const FAILURE_BACKOFF_SECONDS = [60 * 60, 6 * 60 * 60, 24 * 60 * 60]
|
||||
const MAX_FAILURES = FAILURE_BACKOFF_SECONDS.length
|
||||
|
||||
ScrapeCache.SUCCESS_TTL_SECONDS = SUCCESS_TTL_SECONDS
|
||||
ScrapeCache.MAX_FAILURES = MAX_FAILURES
|
||||
|
||||
const now = () => Math.round(Date.now() / 1000)
|
||||
|
||||
const truncate = (value, length) => {
|
||||
if (value == null) { return null }
|
||||
const text = String(value)
|
||||
return text.length > length ? text.substring(0, length) : text
|
||||
}
|
||||
|
||||
ScrapeCache.get = async (urlOrKey) => {
|
||||
const key = /^[0-9a-f]{64}$/.test(urlOrKey) ? urlOrKey : UrlSafety.urlKey(urlOrKey)
|
||||
if (!key) { return null }
|
||||
|
||||
const [rows] = await db.promise().query('SELECT * FROM scrape_cache WHERE url_hash = ? LIMIT 1', [key])
|
||||
return rows.length ? rows[0] : null
|
||||
}
|
||||
|
||||
// Fresh = inside its TTL and successfully fetched.
|
||||
ScrapeCache.isFresh = (row, at) => {
|
||||
if (!row || !row.expires_at) { return false }
|
||||
return row.expires_at > (at || now())
|
||||
}
|
||||
|
||||
ScrapeCache.isUsable = (row) => {
|
||||
return !!(row && row.fetch_state === 'ok' && row.search_text != null)
|
||||
}
|
||||
|
||||
// Has this URL failed enough times that we should stop trying?
|
||||
ScrapeCache.isExhausted = (row) => {
|
||||
return !!(row && row.fetch_state === 'failed' && row.failure_count >= MAX_FAILURES)
|
||||
}
|
||||
|
||||
//
|
||||
// Conditional-request headers for a stale-but-successful row. Null when there
|
||||
// is nothing to revalidate against.
|
||||
//
|
||||
ScrapeCache.conditionalFor = (row) => {
|
||||
if (!row || row.fetch_state !== 'ok') { return null }
|
||||
if (!row.etag && !row.last_modified) { return null }
|
||||
|
||||
return { etag: row.etag || null, lastModified: row.last_modified || null }
|
||||
}
|
||||
|
||||
//
|
||||
// Reserve a row before fetching, so a concurrent save for the same URL finds
|
||||
// it rather than starting a second fetch.
|
||||
//
|
||||
ScrapeCache.markPending = async (url) => {
|
||||
const key = UrlSafety.urlKey(url)
|
||||
if (!key) { return null }
|
||||
|
||||
await db.promise().query(
|
||||
`INSERT INTO scrape_cache (url_hash, url, fetch_state, fetched_at)
|
||||
VALUES (?, ?, 'pending', ?)
|
||||
ON DUPLICATE KEY UPDATE fetch_state = IF(fetch_state = 'ok', fetch_state, 'pending')`,
|
||||
[key, truncate(url, 1000), now()]
|
||||
)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
//
|
||||
// Store a successful scrape.
|
||||
//
|
||||
ScrapeCache.putSuccess = async (url, result) => {
|
||||
const key = UrlSafety.urlKey(url)
|
||||
if (!key) { return null }
|
||||
|
||||
const at = now()
|
||||
const fields = {
|
||||
url: truncate(url, 1000),
|
||||
canonical_url: truncate(result.canonicalUrl, 1000),
|
||||
status: result.status || 200,
|
||||
title: truncate(result.title, 512),
|
||||
site_name: truncate(result.siteName, 255),
|
||||
description: result.description || null,
|
||||
keywords: truncate(result.keywords, 512),
|
||||
search_text: result.searchText || null,
|
||||
image_url: truncate(result.imageUrl, 1000),
|
||||
file_location: truncate(result.fileLocation, 500),
|
||||
etag: truncate(result.etag, 255),
|
||||
last_modified: truncate(result.lastModified, 128),
|
||||
content_type: truncate(result.contentType, 128),
|
||||
fetch_state: 'ok',
|
||||
failure_count: 0,
|
||||
last_error: null,
|
||||
fetched_at: at,
|
||||
expires_at: at + SUCCESS_TTL_SECONDS,
|
||||
}
|
||||
|
||||
const columns = Object.keys(fields)
|
||||
const values = columns.map(column => fields[column])
|
||||
|
||||
await db.promise().query(
|
||||
`INSERT INTO scrape_cache (url_hash, ${columns.join(', ')})
|
||||
VALUES (?, ${columns.map(() => '?').join(', ')})
|
||||
ON DUPLICATE KEY UPDATE ${columns.map(column => `${column} = VALUES(${column})`).join(', ')}`,
|
||||
[key, ...values]
|
||||
)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
//
|
||||
// A 304. The page is unchanged, so only the freshness window moves.
|
||||
//
|
||||
// Deliberately does NOT touch attachment.last_indexed - Attachment.search
|
||||
// orders by it, so revalidating would silently reshuffle the user's list.
|
||||
//
|
||||
ScrapeCache.touchNotModified = async (url) => {
|
||||
const key = UrlSafety.urlKey(url)
|
||||
if (!key) { return null }
|
||||
|
||||
const at = now()
|
||||
await db.promise().query(
|
||||
`UPDATE scrape_cache
|
||||
SET fetched_at = ?, expires_at = ?, fetch_state = 'ok', failure_count = 0, last_error = NULL
|
||||
WHERE url_hash = ?`,
|
||||
[at, at + SUCCESS_TTL_SECONDS, key]
|
||||
)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
//
|
||||
// Record a failure with a widening retry window.
|
||||
//
|
||||
// `blocked` (robots or the safety gate) is terminal: retrying will not change
|
||||
// the answer, so it is parked for the full success TTL instead of backing off.
|
||||
//
|
||||
ScrapeCache.recordFailure = async (url, error) => {
|
||||
const key = UrlSafety.urlKey(url)
|
||||
if (!key) { return null }
|
||||
|
||||
const existing = await ScrapeCache.get(key)
|
||||
const failureCount = (existing ? existing.failure_count : 0) + 1
|
||||
|
||||
const kind = (error && error.kind) || 'unknown'
|
||||
const terminal = kind === 'blocked' || kind === 'robots_disallowed'
|
||||
|
||||
let ttl = null
|
||||
if (terminal) {
|
||||
ttl = SUCCESS_TTL_SECONDS
|
||||
} else if (error && error.retryAfterMs) {
|
||||
ttl = Math.round(error.retryAfterMs / 1000)
|
||||
} else {
|
||||
ttl = FAILURE_BACKOFF_SECONDS[Math.min(failureCount, MAX_FAILURES) - 1]
|
||||
}
|
||||
|
||||
const at = now()
|
||||
const state = terminal ? 'blocked' : 'failed'
|
||||
|
||||
await db.promise().query(
|
||||
`INSERT INTO scrape_cache
|
||||
(url_hash, url, status, fetch_state, failure_count, last_error, fetched_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
fetch_state = VALUES(fetch_state),
|
||||
failure_count = VALUES(failure_count),
|
||||
last_error = VALUES(last_error),
|
||||
fetched_at = VALUES(fetched_at),
|
||||
expires_at = VALUES(expires_at)`,
|
||||
[
|
||||
key,
|
||||
truncate(url, 1000),
|
||||
(error && error.status) || 0,
|
||||
state,
|
||||
failureCount,
|
||||
truncate(`${kind}: ${(error && error.message) || ''}`, 255),
|
||||
at,
|
||||
at + ttl,
|
||||
]
|
||||
)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
//
|
||||
// Project a cache row onto the shape Attachment rows want.
|
||||
//
|
||||
ScrapeCache.toAttachmentFields = (row) => {
|
||||
if (!row) { return null }
|
||||
return {
|
||||
text: row.search_text || '',
|
||||
fileLocation: row.file_location || null,
|
||||
url: row.canonical_url || row.url,
|
||||
}
|
||||
}
|
||||
|
||||
ScrapeCache.purgeExpired = async (limit = 500) => {
|
||||
const [result] = await db.promise().query(
|
||||
`DELETE FROM scrape_cache
|
||||
WHERE expires_at IS NOT NULL AND expires_at < ? AND fetch_state <> 'ok'
|
||||
LIMIT ?`,
|
||||
[now(), limit]
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
@@ -75,7 +75,7 @@ ShareNote.addUserToSharedNote = (userId, noteId, shareUserId, masterKey) => {
|
||||
const sharedUserNoteId = rows[0]['insertId']
|
||||
|
||||
//Emit update count event to user shared with - so they see the note in real time
|
||||
SocketIo.to(shareUserId).emit('update_counts')
|
||||
SocketIo.to(`user:${shareUserId}`).emit('update_counts')
|
||||
|
||||
let success = true
|
||||
return resolve({success, shareUserId, sharedUserNoteId})
|
||||
|
||||
@@ -58,7 +58,7 @@ Tag.addToNote = (userId, noteId, tagText) => {
|
||||
Tag.associateWithNote(userId, noteId, newTagId)
|
||||
.then( result => {
|
||||
|
||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId})
|
||||
SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId})
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,6 @@ let router = express.Router()
|
||||
let Attachment = require('@models/Attachment')
|
||||
let Note = require('@models/Note')
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -19,23 +17,33 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/search', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.search(userId, req.body.noteId, req.body.attachmentType, req.body.offset, req.body.setSize, req.body.includeShared)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/textsearch', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.textSearch(userId, req.body.searchTerm)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/update', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.update(userId, req.body.attachmentId, req.body.updatedText, req.body.noteId)
|
||||
.then( result => {
|
||||
Note.reindex(userId, req.body.noteId)
|
||||
@@ -44,11 +52,19 @@ router.post('/update', function (req, res) {
|
||||
})
|
||||
|
||||
router.post('/delete', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.delete(userId, req.body.attachmentId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/upload', upload.single('file'), function (req, res, next) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
|
||||
//Create attachment with file information and node id
|
||||
const noteId = parseInt(req.body.noteId)
|
||||
@@ -67,6 +83,10 @@ router.post('/upload', upload.single('file'), function (req, res, next) {
|
||||
|
||||
// get push key
|
||||
router.post('/getbookmarklet', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
|
||||
Attachment.getPushkeyBookmarklet(userId)
|
||||
.then( data => res.send(data) )
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// /api/cycle-tracking
|
||||
//
|
||||
|
||||
var express = require('express')
|
||||
var router = express.Router()
|
||||
|
||||
let CycleTracking = require('@models/CycleTracking');
|
||||
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
|
||||
//Session key is required to continue
|
||||
if(!req.headers.sessionId){
|
||||
next('Unauthorized')
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
//Get quick note text
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
CycleTracking.get(userId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Push text to quick note
|
||||
router.post('/save', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
CycleTracking.save(userId, req.body.cycleData, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
|
||||
module.exports = router
|
||||