Compare commits

..
Author SHA1 Message Date
Max 1b47cb0d35 Testing Migrations for web scraper 2026-11-03 13:03:45 -07:00
Max 9804771a5b Testing Migrations 2026-11-03 12:53:17 -07:00
Max d9abeb7fb1 Testing Migrations 2026-11-03 12:50:32 -07:00
Max 81b4162117 Deploy and schema updates for testing 2026-11-03 11:48:06 -07:00
Max 8d60aae65c Adding scrape files for testing 2026-11-03 11:20:03 -07:00
Max 7d255fe687 Small update to style for icons. tweaked pm2 runner to work with deploy script 2026-11-03 11:11:19 -07:00
max b33e2710c3 Update ecosystem for pm2 2026-11-03 17:57:52 +00:00
Max 26e31f1885 Scraper Update for testing 2026-11-03 10:23:03 -07:00
Max 7f38444707 Update host dir and release process 2026-10-29 12:52:50 -07:00
Max 2ce1fc073d Update host dir and release process 2026-10-28 16:30:36 -07:00
Max 42287395fe Big update to deploy scripts, and deploy prosecss 2026-10-28 12:17:09 -07:00
Max 98261207d6 Updated Version 2026-10-28 12:07:03 -07:00
Max 590ae01055 Big update to deploy scripts, and deploy prosecss 2026-10-28 11:54:19 -07:00
Max 2d5d23b104 service worked updates. Needs testing 2026-10-28 11:21:19 -07:00
Max 5bebe072b2 Tons of fixes to prepare for staged releases. 2026-10-28 10:11:40 -07:00
Max 9f37d3faf1 Initial changes testing and running vue3 2026-10-27 12:50:34 -07:00
max 68f71c9d6c Incremental Commit to update backups and use pnpm 2026-06-26 19:23:52 +00:00
max 0b77b0516d Incremental Commit to see WTF I was doing. 2025-09-14 19:13:47 +00:00
Max 276a72b4ce Gigantic Update
* Migrated manual tests to jest and started working on better coverage
* Added a bookmarklet and push key generation tool allowing URL pushing from bookmarklets
* Updated web scraping with tons of bug fixes
* Updated attachments page to handle new push links
* Aggressive note change checking, if patches get out of sync, server overwrites bad updates.
2023-10-17 19:46:14 +00:00
Max b5ef64485f + Giant update to multiple users editing notes.
- Edits are done per DOM element, making diffs smaller and faster
- Multiple users can now edit the same note witheout it turning into a gaint mess
- Caret position is saved better and wont jump around as much

+ Removed Active sessions text
2023-07-30 04:18:17 +00:00
Max c61f0c0198 Graph update and little noe ui tweaks 2023-07-23 23:13:28 +00:00
Max d3acd62688 Deleting unused files 2023-03-02 19:53:32 +00:00
Max a1ca4c3d06 Commiting all changes for repo cleaning 2023-03-02 19:46:51 +00:00
Max 27699cd6fc Updated dnydns script 2023-03-02 01:35:52 +00:00
Max 39e153b8e1 Not really sure what is going on, have not done a commit in a while.
I assume this is all the metric tracking changes.
Looks like some script changes as well.
2023-02-12 18:41:55 +00:00
Max 48c1fa8e69 Added timeout to fetch user totals which prevents
Duplicate calls which would be really annoying
2022-12-22 01:59:27 +00:00
Max 0202d1acda Bugfix Day 1
* Fixed attachments being displayed that were on archived or deleted notes
* Added options to show attachments on archived or trashed notes
* Showing note files will show all attachments for note even if its archived or trashed with mixed file types
* Fixed text about "Flux" theme which was removed
* Fixed bug when opening metric tracking that would prevent default fields from being shown
2022-12-20 19:59:03 +00:00
Max 31473c02ea Added metric tracking and some other little fixes 2022-12-20 17:42:38 +00:00
Max 59f13484a7 Tweaking node versions and project settings
* Removed node sass lets hope it doesn't break anything
2022-10-23 19:37:05 +00:00
Max 0c107a60bd Project restructuring, fixing minor bugs related to vue CLI upgrade
* Removed PWA kit from project, this removes a ton of dependencies
2022-10-23 19:14:31 +00:00
Max 2b76f74dee Added cycle tracking beta to app 2022-10-21 19:34:13 +00:00
Max 1a6a7bdfd4 Updated vue CLI to latest version
Added cycle tracking base
2022-10-13 19:28:35 +00:00
Max G b51e5ac0d0 Adding everything to get started on cycle tracking and maybe avid habit clone 2022-09-25 17:17:41 +00:00
Max G 77cd95fdcb Added paste button and touched up some styles 2022-07-05 05:10:40 +00:00
Max G d94b8c90fc Updated marketing images to change with theme
Removed visible attribute that was left over from testing
Removed drag attribute on check boxes, needs better implimentation later. Drag prevented click events
2022-04-03 17:21:05 +00:00
Max G 0c4f6e94c1 Added focus and interaction to refresh notes that have been changed while user was looking away 2022-02-25 04:26:12 +00:00
Max G bc44b3db9a Lots of little ease of use tweaks 2022-02-25 02:33:49 +00:00
Max G c1797474b8 Updated database script to make it more robust and not break the freaking database when you apply the prod DB to dev 2022-02-25 02:33:20 +00:00
Max G 148b822d49 Tons of littele interface changes and cleanups
Massive update to image scraper with much better image getter
Lots of little ui updates for mobile
2022-01-27 04:48:19 +00:00
Max G b666bfc197 Bunch of changes and unfinished features. Just trying to keep everything up to date. This project is a mess. Don't worry. You are employed. 2021-12-18 22:18:22 +00:00
Max G 7ccf0417e0 Checking in minor changes for server migration 2021-02-12 17:11:33 +00:00
Max G c7e342be4d * Removed unused get note diff function. It doesn't work because everything is encrypted now
* Added a script to sync down the prod database and files to dev
2020-10-10 21:27:52 +00:00
Max G 941d21d9cb Added vue config 2020-10-05 06:46:13 +00:00
Max G e5adaefa0e Updating Everything to work correctly 2020-10-05 06:45:50 +00:00
Max G cf426eba81 * updated server packages 2020-10-04 18:59:30 +00:00
Max G 91e37d368d Updated Squire 2020-10-03 19:15:31 +00:00
Max G b0c487404c * Added an auth screen that isn't integrated at all or working
* Force logout of user with authorization error
* Wrong site blocker doesn't trigger on the solid scribe domain
* Added log out button to main side bar making it easier to find
* Improved icon set for notes
* Colored notes display better on mobile, fixed text color based on color brightness
* Moved terms of use link to the bottom of a few pages
* Updated feature sections on home page, make them clearer and easier to process
* Tweaked color themes
* Deleted links no longer show up in search
* Updated search to use multiple key words
* Updated tests to do a multi word search
* Tweaked a bunch of styles to look better on chrome and browsers
2020-08-03 02:40:27 +00:00
Max G b34a62e114 * Added fake site warning
* Fixed a bunch of style bugs for chrome browsers
* Improved check box styles on desktop and mobile
* Touch up tool tip styles. Only dark now.
* Created a separate terms page
* Added 2FA auth token options to login
* Added tool tip displays to some buttons on editor
* Added pinned and archived options to overflow menu
* Changed shared note styles
* Disabled Scroll into view
* Made image display smaller when adding images to notes
* Added a last used color option
* Updated help page
* Fixed spelling error on terms page
* Added a big ass green label on the new note icon
* Scratch pad now opens a note, which is the scratch pad
* Added better 2fa guide
* Added change password option
* Added log out and log out all active sessions option
* Added strict rate limiting on login and register actions
* Added middleware to routes that force authentication to be accessed
* Fixed bug that was causing shared notes to appear empty
* Updated option now appears on shared notes after they are actually updated
2020-07-23 05:00:20 +00:00
Max G a8a966866c * Added theme colors to form fields
* Added some basic table styles for inserting some shitty tables
* Made popup notification styles look better and work better on mobile
* Quick note now opens a note and not some weird page
* Menu collapses when page is small, behaves like mobile menu
* Added terms and conditions to help and login forms
* Added password change functionality
* Better styles for shared page
* Added some tests for changing password
2020-07-14 05:31:02 +00:00
Max G 06b8f0ad6a Added privacy policy
Updated marketing
Added some keyboard shortcuts
Added settings page
Added accent theming
Added beta 2FA
2020-07-07 04:04:55 +00:00
Max G 2ae84ab73e Update
* Added more version icons
* Added working sign to notes when archived or tagged
* Big sexy marketing update
* Clicking Tags now opens them in their tag category
2020-07-03 03:25:38 +00:00
Max G 8b711ab508 Updated all global and local client packages
* Tweaked sessions to be a little more reliable on mobile
2020-06-21 02:07:36 +00:00
Max G 0bbdda9a2a * Updated server packages 2020-06-21 01:07:27 +00:00
Max G 39b3eef64a Removed some transitions from tooltips 2020-06-21 01:01:12 +00:00
Max G 071aaf22cd * Added Much better session Management, key updating and deleting
* Force reload of JS if app numbers dont match
* Added cool tag display on side of note
* Cleaned up a bunch of code and tweaked little things to be better
2020-06-15 09:02:20 +00:00
Max G d2624628d8 * Added new token system to add more security to logins
* Added simple tag editing from note page
2020-06-10 04:41:52 +00:00
Max G 6bb856689d * Adjusted theme colors to add more contrast on white theme while making black more OLED friendly
* Links now get an underline on hover
* Cleaned up CSS variable names, added another theme color for more control
* Cleaned up unused CSS, removed scrollbars popping up, tons of other little UI tweaks
* Renamed shared notes to inbox
* Tweaked form display, seperated login and create accouts
* Put login/sign up form on home page
* Created more legitimate marketing for home page
* Tons up updates to note page and note input panel
* Better support for two users editing a note
* MUCH better diff handling, web sockets restore notes with unsaved diffs
* Moved all squire text modifier functions into a mixin class
* It now says saving when closing a note
* Lots of cleanup and better handiling of events on mount and destroy
* Scroll behavior modified to load notes when closer to bottom of page
* Pretty decent shared notes and sharable link support
* Updated help text
* Search now includes tag suggestions and attachment suggestions
* Cleaned up scratch pad a ton, allow for users to create new scratch pads
* Created a 404 Page and a Shared note page
* So many other small improvements. Oh my god, what is wrong with me, not doing commits!?
2020-06-07 20:57:35 +00:00
Max G 8e5e06be9b * Small hack to fix images not closing window on mobile
* Made note active text modifier buttons better
* Fixed Colored notes being to big on mobile
2020-05-22 07:08:45 +00:00
Max G 06a140e0d4 * Fixed cursor clicking ToDo lists clicking to early
* Added login form to home page with focus on load
* Tags update after editing tags from title card
* Fixed uploading of images/files
* Fixed images not appearing when opening images tab
* Search hits all categories on search, like archived
* Got rid of brand icons to reduce size
* Got rid of DiffPatchMatch and Crypto from note input panel to reduce size
* Disabled animation on io events so they don't annoy the shit out of people on other computers
2020-05-20 07:57:15 +00:00
Max G 543ecf0f2d Bugfix Batch
* Animations disabled on remote events, closing note still triggers animation for local user
* Created save icons to fix display on mobile
* Hidden URLs are hidden until note is deleted or URL is removed from note
* Tags search all categories, but probably not trash
* Back to all notes button clears search
* Deleted Notes are removed from search index
2020-05-19 03:38:43 +00:00
Max G 5096e74a60 * Removed arrows from notification
* Added trash can function
* Tweaked status text to always be the same
* Removed some open second note code
* Edior always focuses on text now
* Added some extra loading note messages
* Notes are now removed from search index when deleted
* Lots more things happen and update in real time on multiple machines
* Shared notes can be reverted
* WAY more tests
* Note Categories are much more reliable
* Lots of code is much cleaner
2020-05-18 07:45:35 +00:00
Max G e87e8513bc * Made splash page dark and updated description
* Cleaned up unused things
* Updated squire which had a comment typo update...thats it
* Background color picker has matching colors and styles to text color picker
* Added new black theme
* Moved search to main page, show it on mobile and added options to push things to notes from search with experimental tag searching
* Added active note menu buttons based on cursor location in text
* Added more instant updating if app is open in two locations for the same user Scratch Pad and home page update with new notes and new text in real time
2020-05-15 23:12:09 +00:00
Max G 67b218329b * Delete Crunch Menu Component
* Disabled Quick Note
* Note crunches over when menu is open
* Added a cool loader
* Remomoved locked notes
* Added full note encryption
* Added encrypted search index
* Added encrypted shared notes
* Made search bar have a clear and search button
* Tags only loade when clicking on the tags menu
* Tweaked home page to be a little more sane
* built out some gigantic test cases
* simplified a lot of things to make entire app easier to maintain
2020-05-10 21:15:59 +00:00
Max G df073b0e4d Fully Encrypted notes Beta
* Encrypts all notes going to the database
* Creates encrypted snippets for loading note title cards
* Creates an encrypted search index when note is changed
* Migrates users to encrypted notes on login
* Creates new encrypted master keys for newly logged in users
2020-05-06 07:10:27 +00:00
Max G a545ced98f Major Update: Changed Text Input View
* Created new toolbar that moves on mobile
2020-05-02 19:10:20 +00:00
Max G 29845e2294 Tweaked shrinking buttons for better display on mobile 2020-04-16 01:41:47 +00:00
Max G e296775a31 * Tags Dropbown Beta...kinda crappy 2020-04-15 21:54:36 +00:00
Max G 3d2c9868fd * Little Bug Fixes All Around 2020-04-15 20:44:24 +00:00
Max G 9efe21476f * Made dispay of last edit smaller on note title display card
* Made note menu buttons look better on mobile
* Moved around some note menu buttons
* Added a color picker with a rip off of google colors
* Added a remove formatting button
* Hide pin and archive icons, they appear green on hover, in the buttons
* Further simplified display card logic, now it just adds an end tag and returns the data
* Changed highlight text color to show colors (works on chrome...)
2020-04-15 06:28:58 +00:00
Max G 2b8f70b5fa * Added error display to every axios server call
* Added better destroy of login token if invalid
* Block users from opening notes they don't own, note closes automatically
* Beefed up login and home page a little to make them more appealing
2020-04-14 05:09:19 +00:00
Max G 278b204b3b * Added placeholder text to site when loading JS
* Added hidden text to site for scraping
* Login token will be destroyed if fetch site totals is called and the token is bad
* Moved passwords out of application and into a .env file that is loaded on startup
* Changed prod database password for primary user (which is dev)
* Set up .env for dev and prod
2020-04-13 07:44:57 +00:00
Max G 3535f0cb24 * Added some better base information to site for scrapers
* Updated help text
* Refactored a lot of the scrape code into a SiteScrape helper
2020-04-13 06:17:37 +00:00
Max G a3fa4b0f3c Big Update:
* Menus open and close based on URL, allowing for back button on note menus to close

Minor Updates:
* Made night mode buttons green
* Widend the global menu
* Added a version display
* Made the create note button real big
* Made the creane note button more visible on mobile
* Hide the note button if there are no notes
* Changed quick menu item to "Quick Note"
* Added reload option if version is clicked
* Moved around menu buttons at the bottom of the note
* Moved tags back into the main footer on note
* Disabled hiding of toolbar on mobile when editor focused
* Updated locked note display on main title card
* Put last edit on note display
* Tweaked display styles to be more minimal, added fade-in on hover
* Added solid scribe to all title displays on the site
* Reactivated help page and put some good help on it...decent help
* Increased max upload size for files to 5MB
* Shortened text on title display cards to make them all the same size
2020-04-10 03:47:15 +00:00
Max G 25b2bd237b Added package lock 2020-03-30 05:32:46 +00:00
Max G aa4de83de6 * More aggressive dark theme styles, changing default icon colors and notification colors
* Better sortig of archived notes which clicking archived
* Scroll to closed note and show animation on save
* Better notification styles, more obvious
2020-03-30 05:31:09 +00:00
Max G 35605e54d9 Update to latest version of squire. fixes #30
Already update later version of Fomantic. fixes #30
2020-03-29 23:08:22 +00:00
Max G 6dfe753d38 Small change to make user all note option menus fade in 2020-03-29 23:01:37 +00:00
Max G 003153d994 Removed Semantic Added Fomantic 2020-03-26 05:05:31 +00:00
Max G ecbf6a9cde Added rate limiting and server security
Ton of little visual style tweaks and little up improvements for mobile
2020-03-26 04:45:23 +00:00
Max G 749d2cea94 Remove style making double editing weird
fixes #29
2020-03-14 18:52:00 +00:00
Max G 342ac95282 Bug fixes and encryption handling 2020-03-14 06:04:03 +00:00
Max G ca0b649ff6 Pressing enter in a note title moves cursor to end of note. 2020-03-14 02:09:43 +00:00
Max G 4533f6065d Minor bug fixes 2020-03-13 23:51:45 +00:00
Max G f481a97a8c Encrypted Notes Alpha!
fixes #28
2020-03-13 23:34:32 +00:00
Max G f7fc937d26 Updated to later version of vue cli to improve build process
* Updated some simples styles
* Added archive button to main notes
fixes #21
2020-03-11 03:47:07 +00:00
Max G 4c17efceea Added note tags to main note edit display 2020-03-09 03:11:05 +00:00
Max G d3c0d6e2b9 Creating new tags doesn't throw an error
fixes #22
2020-03-04 05:20:14 +00:00
Max G de3391eb94 Tweaked display of note cards, again
* Added an option to pin notes, on the main screen
2020-03-02 05:33:49 +00:00
Max G 2d0beec409 Little green more note indicator.
fixes #23
2020-03-01 00:54:54 +00:00
Max G b838f9f571 Testing new note display cards that use flexbox
Testing new simplified text processes, for smaller notes, it just sends all the text
2020-02-27 07:14:29 +00:00
Max G 72b7f8946a Minor Style Tweaks
* Changed the way the note menus display
* Added a blank note indicator text
* Added date created and date updated text to bottom of note

fixes #18
fixes #5
2020-02-27 04:50:08 +00:00
Max G 902e779a84 Added new status to shared notes
* Shared notes can be new or updated
* New - Note has never been opened
* Updated - Shared note was read but modified by other user
2020-02-24 06:09:28 +00:00
Max G 8f5d8049d0 Shared notes now share updated times
* Updating a shared note, updates the information for other shared users
* Unread shared notes now have a badge
* Updated shared notes now have a badge
* Shared notes can not be reshared, sharer username appears in interface to stop sharing
fixes #15
2020-02-24 06:01:14 +00:00
Max G ef0a6a44c9 * Fixed title display issue on note 2020-02-23 06:46:23 +00:00
Max G fab0b3873f * Fixed a bunch of little bugs
* Added more options to attachment page and filters
* Much better rendering and updating on attachment page
* Math bug is fixed with better string parsing fixes #14
* Icons are limited to 4 per note
* If an image is visible on note preview it will not appear in images preview
* Touched up text algorithm to better display note titles
2020-02-23 06:27:49 +00:00
Max G 7fe702cb1b Added a function to calculate math on notes 2020-02-19 00:31:18 +00:00
Max G aded72928c Better sorting of note categories
fixes #1
2020-02-18 22:52:12 +00:00
Max G e56042958b Added paste event to quick notes allowing for quick note to submit the second data is pasted in
Added option on quick note to submit with enter or CTRL + ENTER
Removed a console log statement.
2020-02-14 05:31:38 +00:00
Max G daa674c54f Added some realtime events to the app
* When a user gets a new shared message, it will popup instantly
* When a new website is scraped, it will update in real time
* Various other little bug fixes and improvements
* Sharing displays correct notes and handles shared notes correctly
* Tags were not displaying on notes, they do now. They better.
2020-02-14 01:08:46 +00:00
Max G a51d81b013 * Search bar only appears in header menu on mobile
* Added tooltip to logout button
* Tags follow archived, inbox, main note fast filters
2020-02-12 05:29:56 +00:00
Max G 771b739c37 Added counts to each category
Counts update on certain events and show or hide various elements
Fixed various little ui element issues

fixes #6
2020-02-11 21:11:14 +00:00
Max G c903bcbcd1 Fixing quick notes
Updating all the icons
making search bar thinner
2020-02-11 06:05:28 +00:00
Max G 9b7fc679e8 * Tags can now be toggled by clicking
* Side slide component now respects note colors
2020-02-10 22:21:06 +00:00
Max G 623d094d7b fixes #4 for real
* Deleting a link deletes the thumbnails
* Joining thumbnails now ignores not visible
2020-02-10 21:09:09 +00:00
Max G ad91218359 fixes #4 Hidden attachment images don't appear on note title display card
* Fixed typo on home page
2020-02-10 20:43:34 +00:00
Max G 97e7988ed1 re #2 Force HTTPS on production when loading home page
* Updated text on home page
2020-02-10 20:03:14 +00:00
Max G 86f7f61933 Created a uniform menu for notes that works on mobile
Added list sorting
Added shared notes
Fixed some little bugs here and there
2020-02-10 17:44:43 +00:00
Max G 133a86e09e Remove TinyMce Added Squire 2020-02-01 22:21:22 +00:00
Max G 68effaa5c3 Some minor bug fixing 2020-01-03 01:54:11 +00:00
Max G abb4e20ec3 I swear, I'm going to start doing regular commits
+ Added a ton of shit
+ About to add socket.io oh god.
2020-01-03 01:26:55 +00:00
Max G 6fe39406b7 I need to get back into using git. The hell is wrong with me!? 2019-12-20 05:50:50 +00:00
159 changed files with 22838 additions and 25299 deletions

No files matched your search

+62
View File
@@ -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 '-------'
+3 -5
View File
@@ -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
+9 -3
View File
@@ -1,18 +1,24 @@
#!/bin/bash
#!/usr/bin/env bash
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
# Take all variables in .env and turn them into local variables for this script
source /home/mab/.env
# 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 -pRootPass1234!" > "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"
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 -f --
##
# Restore DB
##
-31
View File
@@ -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"
+16 -14
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+3 -17
View File
@@ -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).
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
+31
View File
@@ -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>
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
-17741
View File
File diff suppressed because it is too large. Load diff
+22 -22
View File
@@ -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"
}
}
+4725
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+12
View File
@@ -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

+9
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

-69
View File
@@ -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>
+78
View File
@@ -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"
}
}
}
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow:
+22 -15
View File
@@ -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', () => {
+10 -8
View File
@@ -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
+3 -6
View File
@@ -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
+81
View File
@@ -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()
})
}
+28 -5
View File
@@ -17,13 +17,14 @@
body {
margin: 0;
padding: 0;
overflow-x: hidden;
/* overflow-x: hidden;*/
min-width: 320px;
background: #FFFFFF;
background: green;
font-family: 'Roboto', system-ui, -apple-system, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 14px;
line-height: 1.4285em;
color: rgba(0, 0, 0, 0.87);
position: relative;
}
:root {
@@ -95,7 +96,7 @@ body {
font-family: 'Roboto', 'Helvetica Neue', Arial, Helvetica, sans-serif;
}
#app {
background: var(--body_bg_color);
/* background: var(--body_bg_color);*/
}
.ui.segment {
@@ -146,6 +147,9 @@ body {
.ui.dividing.header {
border-bottom-color: var(--dark_border_color);
}
.ui.dividing.header > .sub.header {
color: var(--dark_border_color);
}
.ui.icon.input > i.icon {
color: var(--text_color);
}
@@ -175,15 +179,20 @@ 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;
transition: all 0.9s ease;
position: relative;
}
.button:hover {
box-shadow: 3px 2px 5px -2px rgba(40, 40, 40, 0.95) !important;
box-shadow: 3px 2px 3px -2px rgba(40, 40, 40, 0.95) !important;
}
.button:active {
transform: translateY(1px);
}
.ui.green.buttons, .ui.green.button, .ui.green.button:hover {
background-color: var(--main-accent);
}
@@ -583,6 +592,15 @@ padding-right: 10px;
color: var(--main-accent);
opacity: 1;
}
/* Remove indent line on mobile */
.note-card-text > ol > ol,
.squire-box > ol > ol,
.note-card-text > ul > ul,
.squire-box > ul > ul
{
border-left: none;
}
}
@@ -983,3 +1001,8 @@ padding-right: 10px;
background-color: rgba(0,0,0,0.7);
z-index: 1000;
}
i.outline {
outline-style: none;
}
+1
View File
@@ -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

@@ -117,11 +117,16 @@
<a class="link" :href="linkUrl" target="_blank">{{linkText}}</a>
<!-- Buttons -->
<div class="ui small compact basic button" v-on:click="openNote">
<div v-if="item.note_id" class="ui small compact basic button" v-on:click="openNote">
<i class="file outline icon"></i>
Open Note
</div>
<div class="ui small compact basic button" v-on:click="openEditAttachments"
<div v-if="!item.note_id" class="ui small compact basic disabled button">
<i class="angle double up icon"></i>
Pushed from Web
</div>
<div v-if="item.note_id" class="ui small compact basic button" v-on:click="openEditAttachments"
:class="{ 'disabled':this.searchParams.noteId }">
<i class="folder open outline icon"></i>
Note Files
+2 -1
View File
@@ -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 {
+12 -7
View File
@@ -15,7 +15,7 @@
box-sizing: border-box;
display: block;
position: fixed;
z-index: 111;
z-index: 900;
top: 0;
left: 0;
bottom: 0;
@@ -66,7 +66,7 @@
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.7);
z-index: 100;
z-index: 899;
cursor: pointer;
}
.top-menu-bar {
@@ -87,6 +87,7 @@
margin: 0;
padding: 0;
overflow: hidden;
}
.place-holder {
width: 100%;
@@ -368,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 {
@@ -392,7 +394,7 @@
beforeMount(){
window.addEventListener('resize', this.resizeEventHandler)
},
beforeDestroy(){
beforeUnmount(){
window.removeEventListener('resize', this.resizeEventHandler)
},
mounted: function(){
@@ -526,8 +528,11 @@
location.reload(true)
},
getVersionIcon(){
if(!this.version){
return 'radiation alternate'
}
const icons = ['cat','crow','dog','dove','dragon','fish','frog','hippo','horse','kiwi bird','otter','spider', 'smile', 'robot', 'hat wizard', 'microchip', 'atom', 'grin tongue squint', 'radiation', 'ghost', 'dna', 'burn', 'brain', 'moon', 'torii gate']
const index = ( parseInt(this.version.replace(/\./g,'')) % (icons.length))
const index = ( parseInt(String(this.version).replace(/\./g,'')) % (icons.length))
return icons[index]
}
+28 -19
View File
@@ -4,7 +4,7 @@
<div>
<!-- thicc form display -->
<div v-if="!thin" class="ui large form" v-on:keyup.enter="register()">
<div v-if="!thin" class="ui large form" v-on:keyup.enter="register">
<div class="field">
<div class="ui input">
<input ref="nameForm" v-model="username" type="text" name="email" placeholder="Username or E-mail">
@@ -15,6 +15,11 @@
<input v-model="password" type="password" name="password" placeholder="Password">
</div>
</div>
<div class="field">
<div class="ui input">
<input v-model="password2" type="password" name="password2" placeholder="Re-type Password">
</div>
</div>
<div class="field" v-if="require2FA">
<div class="ui input">
<input v-model="authToken" ref="authForm" type="text" name="authToken" placeholder="Authorization Token">
@@ -24,18 +29,11 @@
<div class="ui fluid buttons">
<div v-on:click="register()" class="ui green button" :class="{ 'disabled':(username.length == 0 || password.length == 0)}">
<div v-on:click="register" class="ui green button" :class="{ 'disabled':(username.length == 0 || password.length == 0)}">
<i class="plug icon"></i>
Sign Up
</div>
<div class="or"></div>
<div :class="{ 'disabled':(username.length == 0 || password.length == 0)}" v-on:click="login()" class="ui button">
<i class="power icon"></i>
Login
</div>
</div>
</div>
<div class="sixteen wide column">
@@ -49,12 +47,12 @@
</div>
<!-- Thin form display -->
<div v-if="thin" class="ui small form" v-on:keyup.enter="login()">
<div v-if="thin" class="ui small form" v-on:keyup.enter="login">
<div v-if="!require2FA" class="field"><!-- hide this field if someone is logging in with 2FA -->
<div class="ui grid">
<div class="ui sixteen wide center aligned column">
<div v-on:click="register()" class="ui green button">
<div v-on:click="register" class="ui green button">
<i class="plug icon"></i>
Sign Up Now!
</div>
@@ -87,7 +85,7 @@
</div>
</div>
<div class="field">
<div v-on:click="login()" class="ui fluid button">
<div v-on:click="login" class="ui fluid button">
<i class="power icon"></i>
Login
</div>
@@ -128,6 +126,7 @@
enabled: false,
username: '',
password: '',
password2: '',
authToken: '',
require2FA: false,
}
@@ -154,19 +153,29 @@
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(){
if( this.username.length == 0 || this.password.length == 0 ){
let error = false
if(this.$route.name == 'LoginPage'){
this.$bus.$emit('notification', 'Both a Username and Password are Required')
return
}
if( this.username.length == 0 || this.password.length == 0 || this.password2.length == 0 ){
this.$bus.$emit('notification', 'All fields are required.')
error = true
}
if( this.password !== this.password2 ){
this.$bus.$emit('notification', 'Passwords must be identical.')
error = true
}
if(error){
//Login section
this.$router.push('/login')
return
-12
View File
@@ -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>
@@ -0,0 +1,348 @@
<style type="text/css" scoped>
.an-graph {
background: #fefefe;
}
.inactive.segment {
}
.active.segment {
outline: 4px solid cyan;
outline-offset: -5px;
outline-style: dashed;
max-height: 2000px;
}
.not-padded {
margin-left: -5px;
margin-right: -5px;
margin-bottom: -10px;
padding-right: 5px;
padding-left: 5px;
}
.sticky-boy {
position: fixed;
top: -1px;
right: 10px;
z-index: 100;
width: 70%;
background: orange;
}
.animate-height {
transition: max-height 0.8s linear;
max-height: 450px;
overflow: hidden;
}
</style>
<template>
<div>
<div class="ui very compact grid" :class="{'sticky-boy':editGraphs}">
<div class="sixteen wide column" v-if="!editGraphs">
<div class="ui basic padded segment">
<!-- Just a space to keep things clickable -->
</div>
</div>
<div class="sixteen wide column">
<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>
</div>
<div v-if="editGraphs">
<div class="ui green button" v-on:click="addGraph()">
<i class="plus icon"></i>
New Graph
</div>
<div class="ui basic button" v-on:click="toggleEditGraphs">
<i class="check circle icon"></i>
Done Editing Graphs
</div>
</div>
</div>
</div>
<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">
<div class="ui grid">
<div class="eight wide column">
<b>Graph #{{ index+1 }}</b>
</div>
<div class="eight wide right aligned column">
<span class="ui tiny compact inverted red button" v-on:click="removeGraph(index)">
Remove Graph
<i class="close icon"></i>
</span>
</div>
</div>
</div>
<h3 class="ui center aligned dividing header">
{{ getGraphTitle(graph) }}
</h3>
<div v-if="graph?.type == PILL_CALENDAR">
<PillCalendarGraph
:graph="graph"
:tempChartDays="tempChartDays"
:userFields="userFields"
:cycleData="cycleData"
:edit-graphs="editGraphs"
:showZeroValues="graph?.options?.showZeroValues"
:showTextValues="graph?.options?.showTextValues"
:connectDays="graph?.options?.connectDays"
:hideValues="graph?.options?.hideValues"
:hideIcons="graph?.options?.hideIcons"
/>
<div v-if="editGraphs" class="ui segment">
<p>Calendar Graph Toggles</p>
<div v-on:click="toggelValue(index, 'hideIcons')"class="ui button">
<span v-if="graph?.options?.hideIcons">Show</span><span v-else>Hide</span> Icons
</div>
<div v-on:click="toggelValue(index, 'hideValues')"class="ui button">
<span v-if="graph?.options?.hideValues">Show</span><span v-else>Hide</span> Values
</div>
<div v-on:click="toggelValue(index, 'showZeroValues')"class="ui button">
<span v-if="!graph?.options?.showZeroValues">Show</span><span v-else>Hide</span> Lowest Value
</div>
<div v-on:click="toggelValue(index, 'showTextValues')"class="ui button">
<span v-if="!graph?.options?.showTextValues">Show</span><span v-else>Hide</span> Text Value
</div>
<div v-on:click="toggelValue(index, 'connectDays')"class="ui button">
<span v-if="!graph?.options?.connectDays">Connect</span><span v-else>Disconnect</span> Days
</div>
</div>
</div>
<div v-if="graph?.type == LAST_DONE">
Last done not implemented
</div>
<div v-if="!graph.fieldIds || graph.fieldIds && graph.fieldIds.length == 0">
<h5>Blank Graph</h5>
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
Select Graph type and Metrics to display
</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':isGraphTypeActive(graphId, graph)}">
{{ graphType }}
</div>
</div>
<div v-for="fieldId in fields">
<span v-if="graph.fieldIds && graph.fieldIds.includes(fieldId)" v-on:click="toggleGraphField(fieldId, index)">
<i class="green check square icon"></i>
</span>
<span v-else v-on:click="toggleGraphField(fieldId, index)">
<i class="square outline icon"></i>
</span>
<i :class="`${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i>
<b>{{ userFields[fieldId]?.label }}</b>
</div>
</div>
</div>
<div class="ui very compact grid" :class="{'sticky-boy':editGraphs}">
<div class="sixteen wide column" v-if="!editGraphs">
<div class="ui basic padded segment">
<!-- Just a space to keep things clickable -->
</div>
</div>
<div class="sixteen wide column">
<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>
</div>
<div v-if="editGraphs">
<div class="ui green button" v-on:click="addGraph()">
<i class="plus icon"></i>
New Graph
</div>
<div class="ui basic button" v-on:click="toggleEditGraphs">
<i class="check circle icon"></i>
Done Editing Graphs
</div>
</div>
</div>
</div>
<!-- Anchor for scrolling to the bottom of graphs -->
<div ref="anchor"></div>
</div>
</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
'fields', // field IDs for display/order
'userFields', // field values defined by user
'graphs', // Graph data defined by user
'cycleData', // ALL user data
'calendar', // Date data for currently open day
'editGraphs' // boolean for edit or not edit graphs
],
components: {
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
},
data: function(){
return {
graphTypesDef:{
// [LAST_DONE]: 'Last Done',
'undefined':'Line Graph (Default)',
[PILL_CALENDAR]:'Calendar Graph',
},
localGraphData:[],
}
},
beforeCreate() {
// Constants
this.PILL_CALENDAR = PILL_CALENDAR
this.LAST_DONE = LAST_DONE
},
mounted(){
this.localGraphData = this.graphs
},
methods: {
saveGraphs(){
this.$emit('saveGraphs', this.localGraphData)
},
toggleEditGraphs(){
setTimeout(() => {
// scroll last graph into view
this.$refs.anchor.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
})
}, 800)
this.$emit('toggleEditGraphs')
},
changeGraphType(index, 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()
},
removeGraph(index){
this.localGraphData.splice(index, 1)
this.saveGraphs()
},
toggelValue(graphIndex, optionName){
if(!this.localGraphData[graphIndex].options){
this.localGraphData[graphIndex].options = {}
}
if(this.localGraphData[graphIndex].options[optionName]){
this.localGraphData[graphIndex].options[optionName] = false
}
else {
this.localGraphData[graphIndex].options[optionName] = true
}
console.log(this.localGraphData[graphIndex].options[optionName])
this.saveGraphs()
},
toggleGraphField(fieldId, graphIndex){
if(!Array.isArray(this.localGraphData[graphIndex].fieldIds)){
this.localGraphData[graphIndex].fieldIds = []
}
const inSetCheck = this.localGraphData[graphIndex]?.fieldIds.indexOf(fieldId)
if(inSetCheck == -1){
this.localGraphData[graphIndex]?.fieldIds.push(fieldId)
}
if(inSetCheck > -1){
this.localGraphData[graphIndex]?.fieldIds.splice(inSetCheck,1)
}
this.saveGraphs()
},
getGraphTitle(graph){
const graphFields = graph?.fieldIds || []
let fieldTitles = []
graphFields.forEach(fieldId => {
fieldTitles.push(this.userFields[fieldId]?.label)
})
// console.log(fieldTitles)
const title = fieldTitles.join(', ')
return title
},
}
}
</script>
@@ -0,0 +1,636 @@
<style type="text/css" scoped>
div.calendar {
width: calc(100% - 4px);
min-height: 350px;
display: flex;
margin: 5px 8px 15px;
flex-wrap: wrap;
flex-direction: row;
justify-content: flex-start;
}
.day {
flex: 0 0 calc(14.28% - 2px);
min-height: 50px;
border: 1px solid var(--border_color);
font-size: 1.2em;
overflow: hidden;
box-sizing: border-box;
position: relative;
line-height: 1em;
display: flex;
align-items: flex-end;
}
.today {
font-weight: bold;
text-decoration: underline;
}
.active-entry {
outline: #07f4f4;
outline-style: none;
outline-width: medium;
outline-style: none;
outline-offset: -1px;
outline-style: solid;
outline-width: 3px;
}
.day ~ .has-data {
}
.day ~ .no-data {
background: #c7c7c787;
opacity: 0.6;
}
.day > .number {
position: absolute;
top: 0;
right: 5px;
z-index: 10;
opacity: 0.4;
}
.day > .sex {
font-size: 0.7em;
border-radius: 5px;
background: rgba(249, 0, 0, 0.15);
color: white;
padding: 0 0 0 4px;
z-index: 10;
position: absolute;
left: 0;
height: 26px;
}
.day > .period {
position: absolute;
bottom: 1px;
left: 1px;
right: 1px;
height: 5px;
background: red;
z-index: 10;
}
.day > .mucus {
position: absolute;
bottom: 0;
left: 0;
right: 0;
min-height: 10px;
background: #abecff7d;
z-index: 2;
}
.day > .notes {
}
.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%;
}
.pill {
width: calc(100% - 8px);
min-height: 2px;
margin: 0 4px;
box-sizing: border-box;
display: inline-block;
background: rgb(50 218 255 / 44%);
border-radius: 40px;
text-align: center;
line-height: 1em;
position: relative;
color: white;
font-size: 0.7em;
padding: 2px;
overflow: hidden;
white-space: nowrap;
}
.pill.did-last {
margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
width: calc(100% - 5px);
}
.pill.did-next {
margin-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
width: calc(100% - 5px);
}
.pill.did-next.did-last {
width: 100%;
}
/* .last-high:after {
content: '';
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 3px solid transparent;
border-left: 10px solid rgb(50 218 255 / 44%);
position: absolute;
left: 0;
top: -13px;
}
.next-high:before {
content: '';
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 3px solid transparent;
border-right: 10px solid rgb(50 218 255 / 44%);
position: absolute;
right: 0;
top: -13px;
}*/
.big-day {
display: inline-block;
width: 100%;
min-height: 2px;
margin: 0 auto;
text-align: center;
}
.zero-day {
opacity: 0.5;
}
.icon-spacer {
display: inline-block;
background-color: greenyellow;
width: 20px;
height: 2px;
}
.past-entries {
width: 100%;
display: flex;
justify-content: space-around;
/* padding: 0 10px;*/
overflow-x: scroll;
overflow-y: hidden;
}
.past-entry {
position: relative;
text-align: center;
border: 1px solid;
border-color: var(--dark_border_color);
color: var(--text_color);
flex-grow: 1;
cursor: pointer;
font-weight: bold;
min-width: 40px;
min-height: 40px;
margin: 5px 0 10px;
line-height: 2.3em;
}
.day-list {
width: 100%;
height: 80px;
background-color: green;
display: flex;
justify-content: space-around;
overflow-x: scroll;
overflow-y: hidden;
}
.day-list-item {
flex-grow: 1;
border: 1px solid black;
width: 25px;
}
.pill.red { background-color: #db2828 }
.pill.orange { background-color: #f2711c }
.pill.yellow { background-color: #fbbd08 }
.pill.olive { background-color: #b5cc18 }
.pill.green { background-color: #21ba45 }
.pill.teal { background-color: #00b5ad }
.pill.blue { background-color: #2185d0 }
.pill.violet { background-color: #6435c9 }
.pill.purple { background-color: #a333c8 }
.pill.pink { background-color: #e03997 }
.pill.brown { background-color: #a5673f }
.pill.grey { background-color: #767676 }
.pill.black { background-color: #1b1c1d }
</style>
<template>
<div>
<div class="calendar">
<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-entry':calendar.dateCode == `${day}.${calendar.month}.${calendar.year}`,
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
'no-data':showDayDataColor(day),
}">
<!-- v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)" -->
<span class="number">{{ day }}</span>
<!-- {{ `${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-if="showZeroValuesCheck(dayData.value, fieldId)"
class="pill"
: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 ${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 ${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>
<script>
// let chartData = {}
export default {
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
props: [
'graph', // options associated with this graph
'userFields', // all field attributes
'tempChartDays', // number of days to display
'cycleData', // all users metric data
'editGraphs', // display additional edit options
// Graph options
'showZeroValues', // Hide graph data with value of zero
'showTextValues', // Show button text or button value
'connectDays', // Calculates next and previous day connections.
'hideValues', // Hide all values on the graph
'hideIcons', // option to hide icons
],
data: function(){
return {
openModel:true,
calendar: {
dateObject: null,
dateCode: null,
monthName: '',
dayName:'',
daysAgo:0,
month: '',
year: '',
days: [],
weekdays: ['S','M','T','W','T','F','S'],
today: 0,
},
chartDateCodes: [], // array of date codes in chart
listDateCodes: [],
dayList: true,
lowestGraphValue: 0,
}
},
mounted(){
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 = {}
let chartValues = []
// iterate every day in month by day code
this.chartDateCodes.forEach((chartDayCode, codeIndex) => {
// lookup data for that day
const cycleDayData = this.cycleData[chartDayCode]
// if chart data is set for this day
if( cycleDayData && Object.keys(cycleDayData).length > 0){
chartData[chartDayCode] = {}
// go over each field to be displayed on graph
this.graph.fieldIds.forEach((graphFieldId) => {
if( cycleDayData[graphFieldId] == undefined ){
return
}
// track all chart values
chartValues.push(cycleDayData[graphFieldId])
chartData[chartDayCode][graphFieldId] = {
didLast: false,
lastHigh: false,
didNext: false,
nextHigh: false,
value: cycleDayData[graphFieldId]
}
})
}
})
this.lowestGraphValue = Math.min(...chartValues)
// determine next and previous states for display
this.chartDateCodes.forEach((chartDayCode, codeIndex) => {
if(chartData[chartDayCode] && this.connectDays){
const previousDateCode = this.chartDateCodes[codeIndex-1]
const nextDateCode = this.chartDateCodes[codeIndex+1]
Object.keys(chartData[chartDayCode]).forEach((graphFieldId) => {
const currentValue = chartData[chartDayCode][graphFieldId].value
// check for previous entry
if( chartData[previousDateCode] && chartData[previousDateCode][graphFieldId] ){
chartData[chartDayCode][graphFieldId].didLast = true
// set low value flag
const lastHigh = chartData[previousDateCode][graphFieldId].value > 0
chartData[chartDayCode][graphFieldId].lastHigh = lastHigh && currentValue == 0
}
// check for next entry
if( chartData[nextDateCode] && chartData[nextDateCode][graphFieldId] ){
chartData[chartDayCode][graphFieldId].didNext = true
// set low value flag
const nextHigh = chartData[nextDateCode][graphFieldId].value > 0
chartData[chartDayCode][graphFieldId].nextHigh = nextHigh && currentValue == 0
}
})
}
})
// console.log(chartData)
return chartData
},
},
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 options = this.userFields[fieldId].customOptions
isBooleanField = options.split(',').length == 2
}
if(isBooleanField && !this.showZeroValues){
const parsedValue = this.getDayValue(fieldId, dayValue)
if(parsedValue == 'Yes'){
return true
} else {
return false
}
}
// 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){
if( !this.showTextValues ){
return value
}
let options = 'error, Yes, No'
if(this.userFields[fieldId].customOptions){
options = this.userFields[fieldId].customOptions
}
const values = options.split(',')
const selection = String(values[value]).trim()
return selection
},
displayDayFromCode(dateCode){
const parts = dateCode.split('.')
return `${parts[0]}`
},
showDayDataColor(day){
// Determine if day has any data set
if(day == ''){
return false
}
return !(this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`])
},
generateDateCode(date){
const dateSetup = [
date.getDate(), // 1-31 (Day)
date.getMonth()+1, // 0-11 (Month)
date.getFullYear(), // 1888-2022 (Year)
]
return dateSetup.join('.')
},
setupCalendar(date){
// visualize each day change
this.working = true
setTimeout(() => {
this.working = false
}, 500)
if(!date && this.dateObject){
date = this.dateObject
}
if(!date){
date = new Date()
}
this.calendar.dateObject = date
this.calendar.dateCode = this.generateDateCode(date)
// calculate days ago since current date
const now = new Date()
const diffSeconds = Math.floor((now - date) / 1000) // subtract unix timestamps, convert MS to S
const dayInterval = diffSeconds / 86400 // seconds in a day
this.calendar.daysAgo = Math.floor(dayInterval)
// ------------
// 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.dayName = date.toLocaleString("en-US", { weekday: "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
// setup date codes for key matching on calendar
this.calendar.days.forEach((day) => {
if( day !== "" ){
let dateDay = new Date(y, m, day);
let dayCode = this.generateDateCode(dateDay)
this.chartDateCodes.push(dayCode)
}
})
// generate past date codes for list
for (let i = 0; i < this.tempChartDays; i++) {
const now = new Date()
const pastDate = now.setDate(now.getDate() - i)
const pastDateObj = new Date(pastDate)
const newCode = this.generateDateCode(pastDateObj)
this.listDateCodes.push(newCode)
}
// return codes.reverse()
/*
October 2022
S M T W T F S
1 2 3 4 5 6
7 8 9
*/
// -------
},
}
}
</script>
+164
View File
@@ -0,0 +1,164 @@
<style type="text/css" scoped>
.modal-content {
position: fixed;
top: 40%;
left: 50%;
/* bring your own prefixes */
transform: translate(-50%, -40%);
z-index: 300;
padding: 1em;
box-sizing: border-box;
width: 50%;
max-height: 100%;
/*overflow: hidden;*/
overflow-y: scroll;
font-weight: normal;
}
.modal-content.fullscreen {
width: 96%;
height: 100%;
max-height: 100%;
}
.close-container {
position: fixed;
top: 5px;
right: 5px;
z-index: 320;
}
/* Shrink button text for mobile */
@media only screen and (max-width: 740px) {
.modal-content {
width: 100%;
/* padding-bottom: 55px;*/
}
}
.modal-content.right-side {
width: 60%;
max-height: none;
height: 100vh;
padding: 0;
margin: 0;
top: 0;
bottom: 0;
left: 0;
left: auto;
transform: translate(0, 0);
}
.close-container-right-side {
position: fixed;
top: 5px;
left: calc(60% + 2px);
z-index: 320;
}
.shade {
position: fixed;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #0000007d;
z-index: 299;
backdrop-filter: blur(2px);
}
.fade-out-top {
animation: fade-out-top 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.fade-out {
animation: fade-out 0.3s ease-out both;
}
@keyframes fade-out-top {
0% {
/*transform: translate(-50%, -50%);*/
opacity: 1;
}
100% {
/*transform: translate(-50%, -70%);*/
opacity: 0;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.fade-in {
/*animation: fade-in 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;*/
}
@keyframes fade-in {
0% {
transform: translate(-50%, -70%);
opacity: 0;
}
100% {
transform: translate(-50%, -50%);
opacity: 1;
}
}
</style>
<template>
<div v-if="openModel">
<div class="modal-content" :class="{ 'fade-out-top':(animateOut), 'fade-in':(!animateOut), 'fullscreen':(fullscreen)}">
<slot></slot>
</div>
<!-- full screen close button -->
<div class="close-container" v-if="fullscreen && clickOutClose !== false">
<div class="ui green icon button" v-on:click="closeModel">
<i class="close icon"></i>
</div>
</div>
<div class="shade" v-on:click="closeModel" v-on:mouseenter=" hoverOutClose?closeModel():null " :class="{ 'fade-out':(animateOut) }"></div>
</div>
</template>
<script>
export default {
props: [
'fullscreen', //Make the model really big
'clickOutClose', //Set to false to prevent closing of modal by clicking out
'hoverOutClose', //Close if cursor leaves modal
],
data: function(){
return {
openModel:true,
animateOut:false,
}
},
methods: {
closeModel(){
//Don't allow closing by clicking out
if(this.clickOutClose === false){
return
}
//Set stups to close model, animate out
this.animateOut = true
setTimeout( () => {
this.openModel = false
this.$emit('close')
//Once close event is sent, reset to default state
this.animateOut = false
this.openModel = true
}, 800)
},
}
}
</script>
+532 -186
View File
@@ -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">
&nbsp;&nbsp;
<i class="ellipsis horizontal icon"></i>
&nbsp;&nbsp;
@@ -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">
@@ -172,8 +172,13 @@
<span class="status-menu" v-on:click=" hash=0; save()">
<span v-if="idleNote" data-position="left center" data-tooltip="Idle: Awaiting Changes">
<i class="vertically flipped grey wifi icon"></i>
</span>
<span v-if="diffsApplied > 0">
+{{ diffsApplied }} Unsaved Changes
<i class="blue wave square icon"></i>
+{{ diffsApplied }}
</span>
<span v-if="usersOnNote > 1" :data-tooltip="`Viewers`" data-position="left center">
@@ -196,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'] }"
@@ -346,35 +351,40 @@
:class="{ 'fade-me-out':sizeDown }"
v-on:click="closeButtonAction()"></div> -->
<div>
</div>
</div>
</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'
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(){
@@ -390,7 +400,6 @@
created: '',
updated: '',
shareUsername: null,
// diffNoteText: '',
statusText: 'saved',
lastNoteHash: null,
saveDebounce: null, //Prevent save from being called numerous times quickly
@@ -402,15 +411,16 @@
pinned: 0,
archived: 0,
attachmentCount: 0,
attachments: [],
styleObject: { 'noteText':null,'noteBackground':null, 'noteIcon':null, 'iconColor':null }, //Style object. Determines colors and badges
sizeDown: false, //Used to animate close state
//Settings vars
lastVisibilityState: null,
//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,
@@ -424,10 +434,14 @@
//Diff text/sync text variables
diffTextTimeout: null,
diffsApplied: null,
idleNote: true, // If note is idle, get updates from server
idleNoteTimeout: null,
reloadNoteDebounce: null,
//Used to restore caret position
lastRange: null,
startOffset: 0,
childIndex: null,
//Tag Display
allTags: [],
@@ -463,29 +477,24 @@
},
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()
this.destroyWebSockets()
document.removeEventListener('visibilitychange', this.checkForUpdatedNote)
window.removeEventListener('blur', this.windowBlurEvent)
window.removeEventListener('focus', this.windowFocusEvent)
//Obliterate squire instance
this.editor.destroy()
@@ -501,7 +510,9 @@
this.forceShowLoading = true
}, 500)
document.addEventListener('visibilitychange', this.checkForUpdatedNote)
window.addEventListener('blur', this.windowBlurEvent)
window.addEventListener('focus', this.windowFocusEvent)
// this.logNoteInteraction()
//Init squire as early as possible
if(this.editor && this.editor.destroy){
@@ -516,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){
@@ -570,33 +581,31 @@
})
},
initSquire(){
initSquireEvents(){
//Set up squire and load note text
this.setText(this.noteText)
// Use squire box HTML for diff/patch changes
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){
this.editor.focus()
this.editor.moveCursorToEnd()
}
//Set up websockets after squire is set up
setTimeout(() => {
this.setupWebSockets()
}, 500)
this.editor.addEventListener('cursor', e => {
//Save range to replace cursor if someone else makes an update
this.lastRange = e.range
this.startOffset = parseInt(e.range.startOffset)
return
this.saveCaretPosition(e)
})
//Change button states on editor when element is active
//eg; Bold button turns green when on bold text
this.editor.addEventListener('pathChange', e => this.pathChangeEvent(e))
this.editor.addEventListener('pathChange', e => {
this.pathChangeEvent(e)
this.diffText(e)
})
//Click Event - Open links when clicked in editor or toggle checks
this.editor.addEventListener('click', e => {
@@ -681,10 +690,20 @@
})
})
//Bind event handlers
this.editor.addEventListener('keyup', event => {
this.onKeyup(event)
this.diffText(event)
this.logNoteInteraction()
})
this.editor.addEventListener('focus', e => {
this.logNoteInteraction()
// this.diffText(e)
})
this.editor.addEventListener('blur', e => {
this.idleNote = true
this.diffText(e)
})
// this.editor.addEventListener("dragstart", e => {
@@ -692,12 +711,6 @@
// console.log(e)
// if(){}
// });
//Show and hide additional toolbars
// this.editor.addEventListener('focus', e => {
// })
// this.editor.addEventListener('blur', e => {
// })
},
openEditAttachment(){
@@ -760,13 +773,18 @@
//Setup all responsive vue data
this.setupLoadedNoteData(response)
this.loading = false
this.$nextTick(() => {
//Adjust note title size after load
this.titleResize()
this.initSquire()
this.initSquireEvents()
//Set up websockets after squire is set up
setTimeout(() => {
this.initWebsocketEvents()
this.loading = false
}, 500)
})
})
@@ -786,14 +804,11 @@
this.created = response.data.created
this.updated = response.data.updated
this.lastInteractionTimestamp = +new Date
this.noteTitle = ''
if(response.data.title){
this.noteTitle = response.data.title
}
this.noteTitle = response.data.title || ''
this.noteText = response.data.text
this.lastNoteHash = this.hashString( response.data.text )
// this.diffNoteText = response.data.text
//Setup note tags
this.allTags = response.data.tags ? response.data.tags.split(','):[]
@@ -807,86 +822,191 @@
this.pinned = response.data.pinned
}
this.archived = response.data.archived
// Fetch attachmets if the count changed
if(response.data.attachment_count > 0){
this.getAttachments()
}
this.attachmentCount = response.data.attachment_count
return true
},
generateSelector(el){
if (!(el instanceof Element))
return;
var path = [];
while (el.nodeType === Node.ELEMENT_NODE) {
var selector = el.nodeName.toLowerCase();
if (el.id) {
selector += '#' + el.id;
path.unshift(selector);
break;
} else {
var sib = el, nth = 1;
while (sib = sib.previousElementSibling) {
if (sib.nodeName.toLowerCase() == selector)
nth++;
}
if (nth != 1)
selector += ":nth-of-type("+nth+")";
}
path.unshift(selector);
el = el.parentNode;
}
return path.join(" > ");
},
//Called on squire event for keyup
diffText(event){
console.log('Skipping diff text function')
return
// console.log(event.type)
//Diff the changed lines only
const diffEvents = ['keyup','pathChange', 'click']
let oldText = this.noteText
// let newText = this.getText()
let newText = document.getElementById('squire-id').innerHTML
const diff = dmp.diff_main(oldText, newText)
// dmp.diff_cleanupSemantic(diff)
const patch_list = dmp.patch_make(oldText, newText, diff);
const patch_text = dmp.patch_toText(patch_list);
if(patch_text == ''){ return }
//Save computed diff text
this.noteText = newText
let newPatch = {
id: this.rawTextId,
diff: patch_text,
// only process changes on certain events
if( !diffEvents.includes(event?.type) ){
return
}
this.$io.emit('note_diff', newPatch)
},
patchText(incomingPatchs){
return new Promise((resolve, reject) => {
clearTimeout(this.diffTextTimeout)
this.diffTextTimeout = setTimeout(() => {
if(incomingPatchs == null){ return resolve(true) }
if(incomingPatchs.length == 0){ return resolve(true) }
// Current Editor Text
const liveEditorElm = this.$refs.squirebox
// let currentText = this.getText()
let currentText = document.getElementById('squire-id').innerHTML
// virtual element for selecting div
let virtualEditorElm = document.createElement('div')
virtualEditorElm.innerHTML = this.rawNoteText
//Convert text of all new patches into patches array
let patches = []
incomingPatchs.forEach(patch => {
// element at cursor
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
if(patch.time <= this.updated){
return
// Remove beginngin selector from path, make it more generic
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
let workingPath = ''
// default to entire note text, select down if path
let selectedDivText = virtualEditorElm
let newSelectedDivText = liveEditorElm
if( path != ''){
const pathParts = path.split(' > ')
let testedPathParts = []
let workingPathParts = []
for (var i = 0; i < pathParts.length; i++) {
testedPathParts.push(pathParts[i])
let currentTestPath = testedPathParts.join(' > ')
// console.log('elm test ',i,currentTestPath)
let elmTest = virtualEditorElm.querySelector(currentTestPath)
if(!elmTest){
break
}
workingPathParts.push(pathParts[i])
}
patches.push(...dmp.patch_fromText(patch.diff))
})
workingPath = workingPathParts.join(' > ')
if(workingPath){
// Select text from virtual editor text
selectedDivText = selectedDivText.querySelector(workingPath)
// select text from current editor text
newSelectedDivText = newSelectedDivText.querySelector(workingPath)
}
if(patches.length == 0){
return resolve(true)
}
var results = dmp.patch_apply(patches, currentText);
let newText = results[0]
const oldDivText = selectedDivText.innerHTML
const newDivText = newSelectedDivText.innerHTML
this.noteText = newText
// this.editor.setHTML(newText)
document.getElementById('squire-id').innerHTML = newText
if(oldDivText == newDivText){ return }
return resolve(true)
const diff = dmp.diff_main(oldDivText, newDivText)
const patch_list = dmp.patch_make(oldDivText, newDivText, diff)
const patch_text = dmp.patch_toText(patch_list)
// save raw text for future diffs
this.rawNoteText = liveEditorElm.innerHTML
let newPatch = {
noteId: this.noteid,
diff: patch_text,
path: path,
// testing metrics
'old text':oldDivText,
'new text':newDivText,
'starting path':path,
'working path':workingPath,
}
// console.log('Sending out patch', newPatch)
this.$io.emit('note_diff', newPatch)
}, 100)
},
patchText(incomingPatchs){
// console.log('incoming patches ', incomingPatchs)
return new Promise((resolve, reject) => {
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 => {
// default to parent element, change to child if set
let editedElement = editorElement
if(patch.path){
editedElement = editorElement.querySelector(patch.path)
}
if( !editedElement ){
editedElement = editorElement
}
// convert patch from text and then apply to selected element
const patches = dmp.patch_fromText(patch.diff)
const patchResults = dmp.patch_apply(patches, editedElement.innerHTML)
// console.log('Patch results')
// console.log([patch.path, editedElement.innerHTML, patchResults[0]])
// patch changed directly into editor
editedElement.innerHTML = patchResults[0]
})
// save editor HTML after change for future comparisons
this.rawNoteText = editorElement.innerHTML
// update hash on patch
this.lastNoteHash = this.hashString( this.rawNoteText )
this.$nextTick(() => {
return resolve(true)
})
})
},
onKeyup(event){
this.statusText = 'modified'
// Small debounce on diff generation
clearTimeout(this.diffTextTimeout)
this.diffTextTimeout = setTimeout(() => {
this.diffText()
}, 25)
this.idleNote = false
//Save after x seconds
clearTimeout(this.editDebounce)
this.editDebounce = setTimeout(() => {
this.save()
}, 5 * 1000)
}, 4 * 1000)
//Save after x keystrokes
this.keyPressesCounter = (this.keyPressesCounter + 1)
@@ -919,7 +1039,8 @@
}
//tell websockets to truncate history at this save
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
this.lastNoteHash = currentHash //Update last saved note hash
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
const postData = {
'noteId': this.currentNoteId,
@@ -939,42 +1060,72 @@
this.modified = true
this.diffsApplied = 0
//Update last saved note hash
this.lastNoteHash = currentHash
return resolve(true)
})
.catch(error => { this.$bus.$emit('notification', 'Failed to Save Note') })
})
},
checkForUpdatedNote(){
loadNoteNextFromServer(){
const now = +new Date
//Only check every 3 seconds
const checkForUpdateTimeout = now - this.lastInteractionTimestamp > (2 * 1000)
if(this.statusText != 'saved'){
return
}
//If user leaves page then returns to page, reload the first batch
if(this.lastVisibilityState == 'hidden' && document.visibilityState == 'visible' && checkForUpdateTimeout){
clearTimeout(this.reloadNoteDebounce)
this.reloadNoteDebounce = setTimeout(() => {
// flash note text to show the update
// 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 => {
const serverTextHash = this.hashString( response.data.text )
if(this.lastNoteHash != serverTextHash){
// console.log('note was changed UPDATE THAT BITCH!!!!')
this.setupLoadedNoteData(response)
//Manually set squire text to show
this.setText(this.noteText)
// 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
}
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) )
})
})
}, 200)
},
windowFocusEvent(){
//Only check if its been greater than a few seconds
const now = +new Date
const checkForUpdateTimeout = now - this.lastInteractionTimestamp > (3 * 1000)
//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
}
//Keep track of visibility change and last interaction time
this.lastVisibilityState = document.visibilityState
},
windowBlurEvent(){
this.idleNote = true
this.lastInteractionTimestamp = +new Date
},
@@ -995,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)
@@ -1030,56 +1190,154 @@
})
},
destroyWebSockets(){
// this.$io.removeListener('past_diffs')
// this.$io.removeListener('update_user_count')
// this.$io.removeListener('incoming_diff')
// 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)
},
setupWebSockets(){
// 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
// 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'),
})
//Apply all diffs since last save
this.$io.on('past_diffs', diffSinceLastUpdate => {
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(){
if(diffSinceLastUpdate != null){
this.diffsApplied = diffSinceLastUpdate.length
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
}
this.patchText(diffSinceLastUpdate)
this.idleNote = false
clearTimeout(this.idleNoteTimeout)
this.idleNoteTimeout = setTimeout(() => {
this.idleNote = true
}, 5000)
},
saveCaretPosition(event){
return
//Find index of child element based on past range
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
//Save range to replace cursor if someone else makes an update
this.lastRange = this.generateSelector(element)
this.startOffset = parseInt(event.range.startOffset) || 0
console.log('Save Range ', [element, this.lastRange, this.startOffset])
return
},
restoreCaretPosition(){
return new Promise((resolve, reject) => {
// This code is intended to restore caret position to previous location
// when a third party updates the note.
if(!this.lastRange){ return resolve(true) }
const editorElement = this.$refs.squirebox
if(!editorElement){ return resolve(true) }
const lastElement = editorElement.querySelector(this.lastRange)
if( !lastElement ){ return resolve(true) }
let range = document.createRange()
range.setStart(lastElement.firstChild, this.startOffset)
range.setEnd(lastElement.firstChild, this.startOffset)
// Set range in editor element
this.editor.setSelection(range)
return resolve(true)
})
this.$io.on('incoming_diff', incomingDiff => {
//Save current caret position
//Find index of child element based on past range
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
const textLines = document.getElementById('squire-id').children
const childIndex = [...textLines].indexOf(element)
this.patchText([incomingDiff])
.then(() => {
if(childIndex == -1){
console.log('Cursor position lost. Div being updated was lost.')
return
}
//Reset caret position
//Find child index of old range and create a new one
let allChildren = document.getElementById('squire-id').children
const newLine = allChildren[childIndex].firstChild
let range = document.createRange()
range.setStart(newLine, this.startOffset)
range.setEnd(newLine, this.startOffset)
this.editor.setSelection(range)
})
})
},
titleResize(){
//Resize the title field
@@ -1090,6 +1348,88 @@
element.style.height = (element.scrollHeight) +'px'
}
},
destroyAttachmentStyles(){
// Remove attachment preview styles
var head = document.head
var styleElement = document.getElementById('attachmentGeneratedStyles'+this.noteid)
if(styleElement){
head.removeChild(styleElement)
}
},
getAttachments(){
axios.post('/api/attachment/search', {'noteId':this.noteid})
.then( results => {
// generate new style group
var style = document.createElement('style')
style.id = 'attachmentGeneratedStyles'+this.noteid
style.type = 'text/css'
// iterate attachments and build unique style for each
let attachmentStyles = []
results.data.forEach(attachment => {
// thumbnail location
const bgurl = `/api/static/thumb_${attachment.file_location}`
let padding = '2px 0 0'
// increase padding if there is a valid file
if(attachment.file_location){
padding = '13px 0 13px 155px'
}
// unescaped characters will break content attribute
const strippedText = attachment.text
.replace(/'/g, "\\'") //Escape ' s
.replace(/\n/g, '\\A') //Escape new lines
// strip down URL, *= matches anywhere is string
const substringsToRemove = ['https://','http://','www.']
var pattern = new RegExp(substringsToRemove.join('|'), 'g');
const strippedurl = attachment.url
.replace(pattern, '') // remove url protocol
.replace(/&.*/, '') // remove anything after &
const cleanStyle = `
.squire-box a[href*="${strippedurl}" i]::before {
content: '${strippedText}';
display: inline-block;
padding: ${padding};
pointer-events: none;
font-size: 1.3em !important;
width: 100%;
background-position: left center;
background-repeat: no-repeat;
background-size: 140px auto;
background-image: url(${bgurl});
border-bottom: solid 1px var(--main-accent);
margin-bottom: -10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.squire-box a[href*="${strippedurl}" i] {
font-size: 0.8em;
}
`
.replace(/\t|\r|\n/gm, "") // Remove tabs, new lines, returns
.replace(/\s+/g, ' ') // remove double spaces
attachmentStyles.push(cleanStyle)
})
// Destroy just before creating new to prevent page jumping
this.destroyAttachmentStyles()
// push new styles into <head>
style.innerHTML = attachmentStyles.join(' ')
document.head.appendChild(style)
})
.catch(error => { console.log(error);this.$bus.$emit('notification', 'Failed to Search Attachments') })
},
}
}
</script>
@@ -1102,6 +1442,11 @@
z-index: 1019;
text-align: right;
}
.status-menu span + span {
border-left: 1px solid #ccc;
margin-left: 4px;
padding-left: 4px;
}
.font-color-bar {
/*width: calc(100% - 8px);*/
@@ -1369,6 +1714,7 @@
}
.edit-button {
padding: 6px 0px 0;
flex-grow: 1;
}
.edit-button > span:not(.ui) {
display: none;
+22 -16
View File
@@ -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(){
@@ -285,23 +286,28 @@
},
justClosed(){
// Dont do anything when not is closed.
// Its already saved, this will make interface feel snappy
// Scroll note into view
this.$el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
})
// this.$el.scrollIntoView({
// behavior: 'smooth',
// block: 'center',
// inline: 'center'
// })
//After scroll, trigger green outline animation
setTimeout(() => {
// this.$bus.$emit('notification','Note Saved')
this.triggerClosedAnimation = true
setTimeout(()=>{
//After 3 seconds, hide it
this.triggerClosedAnimation = false
}, 1500)
// //After scroll, trigger green outline animation
// setTimeout(() => {
}, 500)
// this.triggerClosedAnimation = true
// setTimeout(()=>{
// //After 3 seconds, hide it
// this.triggerClosedAnimation = false
// }, 1500)
// }, 500)
},
},
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div>
<div class="button-fix">
<div class="ui right floated basic shrinking icon button" v-on:click="showPasteInputArea">
<i class="paste icon"></i>
<i class="green paste icon"></i>
Paste
</div>
<div class="shade" v-if="showPasteArea" @click.prevent="close">
+1 -3
View File
@@ -35,7 +35,6 @@
<i class="search icon"></i>
</div>
<div class="floating-button" v-if="searchTerm.length > 0">
<i class="big link grey close icon" v-on:click="clear()"></i>
</div>
@@ -74,7 +73,6 @@
</div>
</div>
</div>
</div>
</span>
</template>
@@ -105,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 -1
View File
@@ -37,7 +37,7 @@
</script>
<style type="text/css" scoped>
.svg-holder >>> svg {
.svg-holder :deep(svg) {
width: 100%;
max-height: 250px;
}
@@ -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(){
+62
View File
@@ -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>
+33 -53
View File
@@ -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')
+154 -141
View File
@@ -171,16 +171,17 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
let container = this.$refs.squirebox
this.$router.go(-1)
Array.from( container.getElementsByClassName('active') ).forEach(item => {
item.classList.remove('active');
})
setTimeout(()=>{
Array.from( container.getElementsByClassName('active') ).forEach(item => {
item.classList.remove('active');
})
},600)
//Close menu if user is on mobile, then sort list
if(this.$store.getters.getIsUserOnMobile){
this.$router.go(-1)
}
},
deleteCompletedListItems(){
//
@@ -188,55 +189,59 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(!checkedItem){
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
}
})
let container = this.$refs.squirebox
//Close menu if user is on mobile, then sort list
if(this.$store.getters.getIsUserOnMobile){
this.$router.go(-1)
}
this.$router.go(-1)
setTimeout(()=>{
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(!checkedItem){
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
}
})
}, 600)
},
sortList(){
//
@@ -244,63 +249,67 @@ const SquireButtonFunctions = {
//
//Fetch the container
let container = document.getElementById('squire-id')
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let doneElements = document.createDocumentFragment()
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(checkedItem){
doneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
doneElements.appendChild( sublist.cloneNode(true) )
}
} else {
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
node.appendChild(doneElements)
}
})
let container = this.$refs.squirebox
//Close menu if user is on mobile
if(this.$store.getters.getIsUserOnMobile){
this.$router.go(-1)
}
this.$router.go(-1)
setTimeout(()=>{
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
if(node.nodeName == 'UL'){
//Create two categories, done and not done list items
let doneElements = document.createDocumentFragment()
let undoneElements = document.createDocumentFragment()
//Go through each item in each list we found
node.childNodes.forEach( (checkListItem, index) => {
//Skip Embedded lists, they are handled with the list item above them. Keep lists with intented items together
if(checkListItem.nodeName == 'UL'){
return
}
//Check if list item has active class
const checkedItem = checkListItem.classList.contains('active')
//Check if the next item is a list, Keep lists with intented items together
let sublist = null
if(node.childNodes[index+1] && node.childNodes[index+1].nodeName == 'UL'){
sublist = node.childNodes[index+1]
}
//Push checked items and their sub lists to the done set
if(checkedItem){
doneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
doneElements.appendChild( sublist.cloneNode(true) )
}
} else {
undoneElements.appendChild( checkListItem.cloneNode(true) )
if(sublist){
undoneElements.appendChild( sublist.cloneNode(true) )
}
}
})
//Remove all HTML from node, push unfinished items, then finished below them
node.innerHTML = null
node.appendChild(undoneElements)
node.appendChild(doneElements)
}
})
},600)
},
calculateMath(){
//
@@ -308,7 +317,10 @@ 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)
// simple function that trys to evaluate javascript
const shittyMath = (string) => {
@@ -322,38 +334,39 @@ const SquireButtonFunctions = {
}
}
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
setTimeout(()=>{
const line = node.innerText.trim()
//Go through each item, on first level, look for Unordered Lists
container.childNodes.forEach( (node) => {
// = sign exists and its the last character in the string
if(line.indexOf('=') != -1 && (line.length-1) == line.indexOf('=')){
const line = node.innerText.trim()
//Pull out everything before the formula and try to evaluate it
const formula = line.split('=').shift()
const output = shittyMath(formula)
// = sign exists and its the last character in the string
if(line.indexOf('=') != -1 && (line.length-1) == line.indexOf('=')){
//If its a number and didn't throw an error, update the line
if(!isNaN(output) && output != null){
//Pull out everything before the formula and try to evaluate it
const formula = line.split('=').shift()
const output = shittyMath(formula)
//Since there is HTML in the line, splice in the number after the = sign
let equalLocation = node.innerHTML.indexOf('=')
let newLine = node.innerHTML.slice(0, equalLocation+1).trim()
newLine += ` ${output}`
newLine += node.innerHTML.slice(equalLocation+1).trim()
//If its a number and didn't throw an error, update the line
if(!isNaN(output) && output != null){
//Slam in that new HTML with the output
node.innerHTML = newLine
//Since there is HTML in the line, splice in the number after the = sign
let equalLocation = node.innerHTML.indexOf('=')
let newLine = node.innerHTML.slice(0, equalLocation+1).trim()
newLine += ` ${output}`
newLine += node.innerHTML.slice(equalLocation+1).trim()
//Slam in that new HTML with the output
node.innerHTML = newLine
}
}
}
})
})
},600)
//Close menu if user is on mobile, then sort list
if(this.$store.getters.getIsUserOnMobile){
this.$router.go(-1)
}
},
setText(inText){
@@ -364,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(){
+19 -4
View File
@@ -8,6 +8,13 @@
<div class="content">
Files
<div class="sub header">Uploaded Files and Websites from notes.</div>
<div class="sub header">
<i class="green angle double up icon icon"></i>
<router-link
to="/bookmarklet">
Push any website to solid scribe
</router-link>
</div>
</div>
</h2>
@@ -95,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 {
@@ -117,7 +125,7 @@
//
// Perform Login check
//
this.$parent.loginGateway()
this.$root.loginGateway()
},
mounted: function(){
@@ -125,13 +133,20 @@
//Load more attachments on scroll
window.addEventListener('scroll', this.onScroll)
this.$io.on('update_note_attachments', () => {
this.reset()
this.searchAttachments()
})
//Mount notes on load if note ID is set
this.searchAttachments()
},
beforeDestroy(){
beforeUnmount(){
//Remove scroll event on destroy
window.removeEventListener('scroll', this.onScroll)
this.$io.removeListener('update_note_attachments')
},
watch:{
$route (to, from){
+66
View File
@@ -0,0 +1,66 @@
<template>
<div class="text-container squire-box">
<h2 class="ui header">
<i class="green angle double up icon icon"></i>
<div class="content">
Push URL to Solid Scribe - Bookmarklet
<div class="sub header">Push any website to your file list.</div>
</div>
</h2>
<p>A bookmarklet is a small piece of code that can be run from a bookmark.</p>
<p>Use the bookmarklet below to push URLs of website to solid scribe for later</p>
<p>The bookmarklet works in a secure way and won't leak any data.</p>
<p>To install the bookmarklet, all you need to do is drag it to your bookmarks bar.</p>
<h2>
Drag the link below to your bookmarks.
</h2>
<h3>
<a :href="`${(bookmarkletscript)}`" class="ui huge text">Push to SolidScribe</a>
</h3>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: {
},
data: function(){
return {
loading: true,
bookmarkletscript:'',
}
},
beforeCreate: function(){
// Perform Login check
this.$root.loginGateway()
},
mounted: function(){
this.getBookmarklet()
},
beforeUnmount(){
},
methods: {
getBookmarklet(){
this.loading = true
axios.post('/api/attachment/getbookmarklet')
.then( results => {
this.bookmarkletscript = results.data
})
.catch(error => { this.$bus.$emit('notification', 'Failed to get bookmarklet') })
},
}
}
</script>
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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>
+19 -11
View File
@@ -12,7 +12,12 @@
animation: fadeorama 16s ease infinite;
height: 350px;
text-shadow: 1px 1px 2px black;
text-shadow:
1px 1px 1px rgba(69,69,69,0.1),
-1px -1px 1px rgba(69,69,69,0.1),
-1px 1px 1px rgba(69,69,69,0.1),
1px -1px 1px rgba(69,69,69,0.1)
;
}
.shine {
position: absolute;
@@ -169,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">
@@ -179,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>
@@ -211,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"
@@ -492,6 +497,8 @@
<a target="_blank" href="https://www.maxg.cc">Solid Scribe was created by Max Gialanella</a>
</h3>
<p><a target="_blank" href="https://www.maxg.cc">Check out my Resume</a></p>
<p>OR</p>
<p><a target="_blank" href="http://blog.maxg.cc">Check out my Programming Blog</a></p>
<p>
I was tired of all my data being owned by big companies, having it farmed out for marketing, and leaving the contents of my life exposed to corporations.
</p>
@@ -525,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 {
@@ -555,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(){
+2 -1
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+152 -71
View File
@@ -12,6 +12,12 @@
<search-input />
</div>
<div class="sixteen wide column" v-if="$store.getters.totals && $store.getters.totals['showTrackMetricsButton']">
<router-link class="ui fluid green button" to="/metrictrack">
<i class="calendar check outlin icon"></i>Metric Track
</router-link>
</div>
<div class="ten wide column" :class="{ 'sixteen wide column':$store.getters.getIsUserOnMobile }">
<div class="ui basic button shrinking"
@@ -31,10 +37,6 @@
<paste-button />
<span class="ui grey text text-fix">
Active Sessions {{ $store.getters.getActiveSessions }}
</span>
</div>
<div class="eight wide column" v-if="showClear">
@@ -110,12 +112,13 @@
</div>
<div class="ui small green button" v-on:click="collapseFloatingList = true">
<i class="caret square left outline icon"></i>
Hide Menu
Hide List
</div>
</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">
@@ -131,6 +134,7 @@
/>
</div>
</div>
</template>
<div class="loading-section" v-if="showLoading">
@@ -154,26 +158,28 @@
</div>
<div class="show-hidden-note-list-button" v-if="collapseFloatingList" v-on:click="collapseFloatingList = false">
<div class="show-hidden-note-list-button"
v-if="collapseFloatingList && openNotes.length > 0" v-on:click="collapseFloatingList = false">
<i class="caret square right outline icon"></i>
</div>
<!-- flexbox note container evenly spaces open notes -->
<div class="note-panel-container" :class="{ 'note-panel-fullwidth':collapseFloatingList}" v-if="openNotes.length">
<template v-for="noteId in openNotes" :key="noteId">
<note-input-panel
v-for="noteId in openNotes"
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'
@@ -181,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 {
@@ -200,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,
@@ -255,7 +264,7 @@
},
beforeMount(){
this.$parent.loginGateway()
this.$root.loginGateway()
//If user is on title view,
this.titleView = this.$store.getters.getIsUserOnMobile
@@ -360,7 +369,7 @@
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
},
beforeDestroy(){
beforeUnmount(){
window.removeEventListener('scroll', this.onScroll)
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
@@ -377,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()
@@ -392,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]){
@@ -429,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){
@@ -564,7 +642,7 @@
// @TODO Don't even trigger this if the note wasn't changed
updateSingleNote(noteId, focuseAndAnimate = true){
console.log('updating single note', noteId)
// console.log('updating single note', noteId)
noteId = parseInt(noteId)
@@ -738,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(){
+14 -11
View File
@@ -106,7 +106,8 @@
<div :class="{'one-column':( showOneColumn() )}">
<!-- 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">
@@ -122,6 +123,7 @@
/>
</div>
</div>
</template>
<loading-icon v-if="loadingInProgress" message="Decrypting Notes" />
@@ -143,6 +145,7 @@
</template>
<script>
import { defineAsyncComponent } from 'vue'
import axios from 'axios'
@@ -150,15 +153,15 @@
name: 'SearchBar',
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,
'counter':require('@/components/AnimatedCounterComponent.vue').default,
'tag-display':require('@/components/TagDisplayComponent.vue').default,
'loading-icon':require('@/components/LoadingIconComponent.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')),
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
},
data () {
return {
@@ -222,7 +225,7 @@
},
beforeMount(){
this.$parent.loginGateway()
this.$root.loginGateway()
this.$io.on('new_note_created', noteId => {
@@ -335,7 +338,7 @@
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
},
beforeDestroy(){
beforeUnmount(){
window.removeEventListener('scroll', this.onScroll)
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
+1 -1
View File
@@ -86,7 +86,7 @@
//
// Perform Login check
//
this.$parent.loginGateway()
this.$root.loginGateway()
},
beforeMount(){
+52 -2
View File
@@ -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
+148
View File
@@ -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 &mdash; 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>
+48
View File
@@ -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()
}
+38 -123
View File
@@ -1,126 +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 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: '/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: "CycletrackingPage" */ '@/pages/CycletrackingPage')
},
]
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 },
],
})
-15
View File
@@ -1,15 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
+31 -10
View File
@@ -1,16 +1,13 @@
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,
isUserOnMobile: false,
fetchTotalsTimeout: null,
userTotals: null,
userTotals: null, // {} // setting this to object breaks reactivity
activeSessions: 0,
},
mutations: {
@@ -44,7 +41,7 @@ export default new Vuex.Store({
'menu-text': '#5e6268',
},
'black':{
'body_bg_color': 'linear-gradient(135deg, rgba(0,0,0,1) 0%, rgba(23,12,46,1) 100%)',
'body_bg_color': 'rgb(12 4 30)',
//'#0f0f0f',//'#000',
'small_element_bg_color': '#000',
'text_color': '#FFF',
@@ -101,8 +98,23 @@ export default new Vuex.Store({
state.socket = socket
},
setUserTotals(state, totalsObject){
//Save all the totals for the user
state.userTotals = totalsObject
if(!state.userTotals){
state.userTotals = {}
}
// retain old values loaded on initial, extended options load
let oldMissingValues = {}
Object.keys(state.userTotals).forEach(key => {
if(!totalsObject[key] && totalsObject[key] !== 0){
oldMissingValues[key] = state.userTotals[key]
}
})
// combine old settings with updated settings
let oldAndNew = Object.assign(oldMissingValues, totalsObject)
state.userTotals = oldAndNew
//Set computer version from server
const currentVersion = localStorage.getItem('currentVersion')
@@ -126,6 +138,11 @@ export default new Vuex.Store({
setActiveSessions(state, countData){
//Count of the number of active socket.io sessions for this user
state.activeSessions = countData
},
hideMetricTrackingReminder(state){
if(state.userTotals){
state.userTotals['showTrackMetricsButton'] = false
}
}
},
getters: {
@@ -159,7 +176,11 @@ export default new Vuex.Store({
fetchAndUpdateUserTotals ({ commit, state }) {
clearTimeout(state.fetchTotalsTimeout)
state.fetchTotalsTimeout = setTimeout(() => {
axios.post('/api/user/totals')
// load extended options on initial load
let postData = {
extendedOptions: !state.userTotals
}
axios.post('/api/user/totals', postData)
.then( ({data}) => {
commit('setUserTotals', data)
})
+79
View File
@@ -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;
}
}
+25
View File
@@ -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";
+114
View File
@@ -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 },
},
},
})
+103
View File
@@ -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.
Executable
+225
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
//
// 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/(.*)$": "<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",
}
}
+292
View File
@@ -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 '-------'
-4243
View File
File diff suppressed because it is too large. Load diff
+8 -6
View File
@@ -1,16 +1,16 @@
{
"name": "personal-internet",
"version": "1.0.0",
"description": "Personal or Private net",
"description": "Encrypted note taking applications",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "jest"
},
"author": "Max",
"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,6 +30,10 @@
"@models": "server/models",
"@routes": "server/routes",
"@helpers": "server/helpers",
"@config": "server/config"
"@config": "server/config",
"@workers": "server/workers"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
+3965
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -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`);
+211
View File
@@ -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;
+3
View File
@@ -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"
+151
View File
@@ -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)
}
+24
View File
@@ -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`);
+2
View File
@@ -1,5 +1,7 @@
//Import mysql2 package
const mysql = require('mysql2');
const os = require('os') //Used to get path of home directory
const result = require('dotenv').config({ path:(os.homedir()+'/.env') })
// Create the connection pool.
const pool = mysql.createPool({
+70
View File
@@ -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
+8 -11
View File
@@ -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'
}
}
};
+195
View File
@@ -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
}
+2
View File
@@ -72,6 +72,8 @@ CryptoString.createSalt = () => {
return crypto.randomBytes(SALT_BYTE_SIZE).toString('base64')
}
// Creates a small random salt
CryptoString.createSmallSalt = () => {
return crypto.randomBytes(20).toString('base64')
+221
View File
@@ -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()
+429
View File
@@ -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.
//
+43
View File
@@ -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 }
+216
View File
@@ -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
}
+239
View File
@@ -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
}
+409 -173
View File
@@ -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 = ['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
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']
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,162 +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,3000) //Limit to 3000 characters
.toLowerCase()
//Count frequency of each word in scraped text
let frequency = {}
majorContent.split(' ').forEach(word => {
if(commonWords.includes(word)){
return //Exclude certain words
}
if(!frequency[word]){
frequency[word] = 0
}
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<5; i++){
if(sortable[i] && sortable[i][0]){
finalWords.push(sortable[i][0])
}
}
if(finalWords.length > 0){
return 'Keywords: ' + finalWords.join(', ')
}
return ''
}
SiteScrape.getMainText = ($) => {}
+246
View File
@@ -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)
}
+183 -98
View File
@@ -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')
@@ -20,6 +26,8 @@ const helmet = require('helmet')
const express = require('express')
const app = express()
app.use( helmet() )
// allow for the parsing of url encoded forms
app.use(express.urlencoded({ extended: true }));
//
@@ -46,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
@@ -54,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
@@ -69,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)
@@ -102,102 +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){
Auth.decodeToken(token)
.then(userData => {
//Sort all note diffs by when they were created.
noteDiffs[rawTextId].sort((a,b) => { return a.time - b.time })
return Note.getRawTextIdForUser(noteId, userData.userId)
.then(rawTextId => {
//Emit all sorted diffs to user
socket.emit('past_diffs', noteDiffs[rawTextId])
} else {
socket.emit('past_diffs', null)
}
// No claim on this note -> no room, no diffs
if(rawTextId == null){ return }
const usersInRoom = io.sockets.adapter.rooms[rawTextId]
if(usersInRoom){
//Update users in room count
io.to(rawTextId).emit('update_user_count', usersInRoom.length)
const room = noteRoom(rawTextId)
//Debugging text - prints out notes in limbo
let noteDiffKeys = Object.keys(noteDiffs)
let totalDiffs = 0
noteDiffKeys.forEach(diffSetKey => {
if(noteDiffs[diffSetKey]){
totalDiffs += noteDiffs[diffSetKey].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.join(room)
//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 })
//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)
}
})
//Debugging Text
if(noteDiffKeys.length > 0){
console.log('Total notes in limbo -> ', noteDiffKeys.length)
console.log('Total Diffs for all notes -> ', totalDiffs)
}
}
})
.catch(() => {
//Not logged in, or no access. Join nothing.
})
})
socket.on('leave_room', roomId => {
socket.leave(roomId)
// console.log('User Left room')
socket.on('leave_room', ({ noteId } = {}) => {
const usersInRoom = io.sockets.adapter.rooms[roomId]
// 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){
// console.log('Users in room', usersInRoom.length)
io.to(roomId).emit('update_user_count', usersInRoom.length)
io.to(entry.room).emit('update_user_count', usersInRoom.length)
}
})
socket.on('note_diff', data => {
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
const noteId = data.id
delete data.id
if(noteDiffs[noteId] == undefined){ noteDiffs[noteId] = [] }
data.time = +new Date
if(noteDiffs[rawTextId] == undefined){ noteDiffs[rawTextId] = [] }
noteDiffs[noteId].push(data)
// 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
//Remove duplicate diffs if they exist
for (var i = noteDiffs[noteId].length - 1; i >= 0; i--) {
let pastDiff = noteDiffs[noteId][i]
for (var j = noteDiffs[noteId].length - 1; j >= 0; j--) {
let currentDiff = noteDiffs[noteId][j]
if(i == j){
continue
}
if(currentDiff.diff == pastDiff.diff || currentDiff.time == pastDiff.time){
console.log('Removing Duplicate')
noteDiffs[noteId].splice(i,1)
}
}
}
//Each user joins a room when they open the app.
io.in(noteId).clients((error, clients) => {
if (error) throw error;
//Go through each client in note room and send them the diff
clients.forEach(socketId => {
if(socketId != socket.id){
io.to(socketId).emit('incoming_diff', data)
}
})
});
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 => {
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){
@@ -213,7 +233,6 @@ io.on('connection', function(socket){
}
}
noteDiffs[checkpoint.rawTextId] = diffSet.slice(0, sliceTo)
if(noteDiffs[checkpoint.rawTextId].length == 0){
@@ -235,7 +254,7 @@ io.on('connection', function(socket){
http.listen(ports.socketIo, function(){
console.log(`Socke.io: Listening on port ${ports.socketIo}!`)
console.log(`Socke.io: Listening on port ${ports.socketIo}`)
});
//Enable json body parsing in requests. Allows me to post data in ajax calls
@@ -276,17 +295,21 @@ app.use(function(req, res, next){
// Test Area
const printResults = true
let UserTest = require('@models/User')
let NoteTest = require('@models/Note')
let AuthTest = require('@helpers/Auth')
Auth.test()
UserTest.keyPairTest('genMan30', '1', printResults)
.then( ({testUserId, masterKey}) => NoteTest.test(testUserId, masterKey, printResults))
.then( message => {
if(printResults) console.log(message)
Auth.testTwoFactor()
})
// const printResults = true
// let UserTest = require('@models/User')
// let NoteTest = require('@models/Note')
// let AuthTest = require('@helpers/Auth')
// Auth.test()
// UserTest.keyPairTest('genMan30', '1', printResults)
// .then( ({testUserId, masterKey}) =>
// NoteTest.test(testUserId, masterKey, printResults))
// .then( message => {
// if(printResults) console.log(message)
// Auth.testTwoFactor()
// })
// .catch((error) => {
// console.log(error)
// })
//Test
app.get('/api', (req, res) => res.send('Solidscribe /API is up and running'))
@@ -322,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
+359 -243
View File
@@ -1,19 +1,35 @@
let db = require('@config/database')
let SiteScrape = require('@helpers/SiteScrape')
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) => {
@@ -47,13 +63,15 @@ Attachment.textSearch = (userId, searchTerm) => {
}
Attachment.search = (userId, noteId, attachmentType, offset, setSize, includeShared) => {
console.log([userId, noteId, attachmentType, offset, setSize, includeShared])
return new Promise((resolve, reject) => {
let params = [userId]
let query = `
SELECT attachment.*, note.share_user_id FROM attachment
JOIN note ON (attachment.note_id = note.id)
WHERE attachment.user_id = ? AND visible = 1 `
LEFT JOIN note ON (attachment.note_id = note.id)
WHERE attachment.user_id = ? AND visible = 1
`
if(noteId && noteId > 0){
//
@@ -76,6 +94,11 @@ Attachment.search = (userId, noteId, attachmentType, offset, setSize, includeSha
query += `AND note.archived = ${ attachmentType == 'archived' ? '1':'0' } `
query += `AND note.trashed = ${ attachmentType == 'trashed' ? '1':'0' } `
if(!attachmentType){
// Null note ID means it was pushed by bookmarklet
query += 'OR attachment.note_id IS NULL '
}
}
@@ -102,18 +125,6 @@ Attachment.search = (userId, noteId, attachmentType, offset, setSize, includeSha
})
}
//Returns all attachments
Attachment.forNote = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
.query(`SELECT * FROM attachment WHERE user_id = ? AND note_id = ? AND visible = 1 ORDER BY last_indexed DESC;`, [userId, noteId])
.then((rows, fields) => {
resolve(rows[0]) //Return all attachments found by query
})
.catch(console.log)
})
}
Attachment.urlForNote = (userId, noteId) => {
return new Promise((resolve, reject) => {
db.promise()
@@ -189,6 +200,7 @@ Attachment.delete = (userId, attachmentId, urlDelete = false) => {
.catch(console.log)
}
})
.catch(console.log)
})
}
@@ -266,276 +278,380 @@ 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')
solrAttachmentText += freshlyScrapedText
resolve(solrAttachmentText)
})
})
})
}
//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){
resolve(scrapedText)
}
})
})
})
}
Attachment.downloadFileFromUrl = (url) => {
return new Promise((resolve, reject) => {
if(url == null || url == undefined || url == ''){
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')
console.log(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 = 20*1000;
console.log('[scrape] gm unavailable or failed, using full size as thumbnail')
fs.promises.copyFile(fullPath, thumbPath)
.then(() => resolve(true))
.catch(() => resolve(false))
})
})
}
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) => {
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])
.query("SELECT pushkey FROM user WHERE id = ? LIMIT 1", [userId])
.then((rows, fields) => {
//Set two bigger variables then return request for processing
request = rp(options)
insertedId = rows[0].insertId
return request
})
.then($ => {
const pushKey = rows[0][0].pushkey
//Clear timeout that would end this function
clearTimeout(requestTimeout)
// push key exists
if(pushKey && pushKey.length > 0){
// let header = $('h1').text().replace(removeWhitespace, " ")
// desiredSearchText += header + "\n"
return resolve(pushKey)
const pageTitle = SiteScrape.getTitle($)
} else {
const hostname = SiteScrape.getHostName(url)
// generate and save a new key
const newPushKey = cs.createSmallSalt()
db.promise()
.query('UPDATE user SET pushkey = ? WHERE id = ? LIMIT 1', [newPushKey,userId])
.then((rows, fields) => {
const thumbnail = SiteScrape.getDisplayImage($, url)
const keywords = SiteScrape.getKeywords($)
var desiredSearchText = ''
desiredSearchText += pageTitle
if(keywords){
desiredSearchText += "\n" + keywords
return resolve(newPushKey)
})
}
console.log({
pageTitle,
hostname,
thumbnail,
keywords
})
})
})
}
Attachment.deletePushKey = (userId) => {
return new Promise((resolve, reject) => {
// throw new Error('Ending this function early.')
db.promise()
.query('UPDATE user SET pushkey = null WHERE id = ? LIMIT 1', [userId])
.then((rows, fields) => {
return resolve(rows[0].affectedRows == 1)
})
})
}
// console.log('TexT Scraped')
// console.log(desiredSearchText)
Attachment.getPushkeyBookmarklet = (userId) => {
return new Promise((resolve, reject) => {
created = Math.round((+new Date)/1000)
Attachment.generatePushKey(userId)
.then( pushKey => {
//Scrape URL for thumbnail - take filename and save in attachment
Attachment.downloadFileFromUrl(thumbnail)
.then(thumbnailFilename => {
let bookmarklet = Attachment.generateBookmarkletText(pushKey)
return resolve(bookmarklet)
//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
})
})
}
//
// 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) => {
const ScrapeQueue = require('@workers/ScrapeQueue')
const Schema = require('@helpers/Schema')
// 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]) => {
// Unchanged: an unknown pushkey resolves truthy and leaks nothing
// about whether the key exists.
if (rows.length == 0) { return null }
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
})
.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)
})
})
.then(() => {
if (userId) {
Realtime.emitToUser(userId, 'update_counts')
Realtime.emitToUser(userId, 'update_note_attachments')
}
return true
})
.catch(error => {
// console.log('Scrape pooped out')
// console.log('Issue with scrape')
console.log(error)
// resolve('')
console.log('[scrape] pushUrl failed', error.message)
return true
})
}
requestTimeout = setTimeout( () => {
console.log('Cancel the request, its taking to long.')
request.cancel()
desiredSearchText = 'No Description for -> '+url
created = Math.round((+new Date)/1000)
db.promise()
.query(`UPDATE attachment SET
text = ?,
last_indexed = ?,
WHERE id = ?
`, [desiredSearchText, created, 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)
// VALUES (?, ?, ?, ?, ?, ?)`, [noteId, userId, 1, desiredSearchText, url, created])
// .then((rows, fields) => {
// resolve(desiredSearchText) //Return found text
// })
// .catch(console.log)
}, scrapeTime )
})
Attachment.generateBookmarkletText = (pushKey) => {
const endpoint = '/api/public/pushmebaby'
let url = 'https://www.solidscribe.com' + endpoint
if(process.env.NODE_ENV === 'development'){
// url = 'https://192.168.1.164' + endpoint
}
// Terminate each line with a semi-colon, super important, since spaces are removed.
// document.getElementById(id).remove();
url += '?pushkey='+encodeURIComponent(pushKey)
const bookmarkletV3 = `
javascript: (() => {
var p = encodeURIComponent(window.location.href);
var n = "`+url+`&url="+p;
window.open(n, '_blank', 'noopener=noopener');
window.focus();
var k = document.createElement("div");
k.setAttribute("style", "position:fixed;right:10px;top:10px;z-index:222222;border-radius:4px;font-size:1.3em;padding:20px 15px;background: #8f51be;color:white;");
k.innerHTML = "Posted URL to your Solid Scribe account";
document.body.appendChild(k);
setTimeout(()=>{
k.remove();
},5000);
})();
`
return bookmarkletV3
.replace(/\t|\r|\n/gm, "") // Remove tabs, new lines, returns
.replace(/\s+/g, ' ') // remove double spaces
.trim()
}
+66
View File
@@ -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)
})
})
}
+25 -20
View File
@@ -2,7 +2,7 @@ let db = require('@config/database')
let Note = require('@models/Note')
let MetricTracking = module.exports = {}
let MetricTracking = module.exports = {};
MetricTracking.get = (userId, masterKey) => {
@@ -23,31 +23,36 @@ MetricTracking.get = (userId, masterKey) => {
})
} else {
//Or create a new note
let finalId = null
return Note.create(userId, 'Metric 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)
})
})
})
return resolve('no data')
}
})
.catch(console.log)
})
}
MetricTracking.save = (userId, cycleData, masterKey) => {
MetricTracking.create = (userId, masterKey) => {
return new Promise((resolve, reject) => {
let finalId = null
return Note.create(userId, 'Metric 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)
})
}
MetricTracking.save = (userId, metricData, masterKey) => {
return new Promise((resolve, reject) => {
let finalId = null
@@ -55,7 +60,7 @@ MetricTracking.save = (userId, cycleData, masterKey) => {
MetricTracking.get(userId, masterKey)
.then(noteObject => {
return Note.update(userId, noteObject.id, cycleData, noteObject.title, noteObject.color, noteObject.pinned, noteObject.archived, null, masterKey)
return Note.update(userId, noteObject.id, metricData, noteObject.title, noteObject.color, noteObject.pinned, noteObject.archived, null, masterKey)
})
.then( saveResults => {
+57 -21
View File
@@ -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,11 +13,11 @@ 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')
Note.test = (userId, masterKey, printResults) => {
return false;
return new Promise((resolve, reject) => {
@@ -162,6 +163,10 @@ Note.test = (userId, masterKey, printResults) => {
return resolve('Test: Complete ---')
})
.catch(error => {
console.log(error)
return reject(error)
})
})
}
@@ -193,8 +198,8 @@ Note.create = (userId, noteTitle = '', noteText = '', masterKey) => {
})
.then((rows, fields) => {
if(SocketIo){
SocketIo.to(userId).emit('new_note_created', rows[0].insertId)
if(typeof SocketIo != 'undefined'){
SocketIo.to(`user:${userId}`).emit('new_note_created', rows[0].insertId)
}
// Indexing is done on save
@@ -341,7 +346,7 @@ Note.reindex = (userId, masterKey, removeId = null) => {
setTimeout(() => {
if(masterKey == null || note.salt == null){
console.log('Error indexing note', note.id)
console.log('Error indexing note - master key or salt missing', note.id)
return resolve(true)
}
@@ -390,13 +395,13 @@ Note.reindex = (userId, masterKey, removeId = null) => {
return Promise.all(reindexQueue)
})
.then(rawSearchIndex => {
.then(updatePromiseResults => {
const created = Math.round((+new Date)/1000)
const jsonSearchIndex = JSON.stringify(searchIndex)
const encryptedJsonIndex = cs.encrypt(masterKey, searchIndexSalt, jsonSearchIndex)
return db.promise().query("UPDATE user_encrypted_search_index SET `index` = ?, `last_update` = ? WHERE (`user_id` = ?) LIMIT 1",
db.promise().query("UPDATE user_encrypted_search_index SET `index` = ?, `last_update` = ? WHERE (`user_id` = ?) LIMIT 1",
[encryptedJsonIndex, created, userId])
.then((rows, fields) => {
@@ -406,6 +411,7 @@ Note.reindex = (userId, masterKey, removeId = null) => {
.then((rows, fields) => {
// console.log('Indexd Note Count: ' + rows[0]['affectedRows'])
// @TODO - Return number of reindexed notes
resolve(true)
})
@@ -484,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})
})
}
})
@@ -507,12 +513,15 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
})
.then((rows, fields) => {
if(SocketIo){
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)
Attachment.scanTextForWebsites(userId, noteId, noteText)
.catch(error => console.log('scanTextForWebsites failed', error.message))
//Send back updated response
resolve(rows[0])
@@ -532,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)
})
})
@@ -549,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)
})
})
@@ -575,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)
})
})
@@ -647,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
@@ -739,12 +748,13 @@ Note.get = (userId, noteId, masterKey) => {
const nowTime = Math.round((+new Date)/1000)
db.promise().query(`UPDATE note SET opened = ? WHERE (id = ?)`, [nowTime, noteId])
//Return note data
// delete noteData.salt //remove salt from return data
// delete noteData.encrypted_share_password_key
noteData.lockedOut = noteLockedOut
resolve(noteData)
.then(results => {
//Return note data
// delete noteData.salt //remove salt from return data
// delete noteData.encrypted_share_password_key
noteData.lockedOut = noteLockedOut
resolve(noteData)
})
})
.catch(error => {
@@ -1184,3 +1194,29 @@ Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => {
})
}
//
// 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)
})
})
}
+224
View File
@@ -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
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more