├── .env.development ├── .env.template ├── .gitignore ├── LICENSE ├── README.md ├── cert ├── CA.key ├── CA.pem └── localhost │ ├── .srl │ ├── localhost.crt │ ├── localhost.csr │ ├── localhost.decrypted.key │ ├── localhost.ext │ └── localhost.key ├── config-overrides.js ├── deploy.js ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── server.js ├── src ├── App.css ├── App.js ├── App.test.js ├── FolderList.js ├── Loading.js ├── Tweet.js ├── cookies.js ├── index.css ├── index.js ├── logo.svg ├── reportWebVitals.js ├── setupTests.js └── utils.js ├── twitter-oauth └── index.js └── yarn.lock /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_URL=https://127.0.0.1:3002 -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | TWITTER_CLIENT_ID= 2 | COOKIE_SECRET=pan_con_tomate 3 | TWITTER_REDIRECT_URI= 4 | PORT=3002 5 | APP_URL=https://127.0.0.1:3000 6 | REDIRECT_URI=http://127.0.0.1:3000/oauth 7 | PROXY=https://127.0.0.1:3002 8 | HTTPS=true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | src/mock.js 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bookmark search 2 | 3 | Search your Twitter bookmarks 4 | 5 | [Try it live](https://bookmarksearch.glitch.me) 6 | 7 | [Sign up for the Twitter API](https://t.co/signup) 8 | 9 | ## How it works 10 | 11 | This app uses the Bookmarks lookup endpoint to get your bookmarks. It then performs a fuzzy search on: 12 | 13 | - The Tweet text 14 | - The author's name and username 15 | - The [Tweet annotations](https://developer.twitter.com/en/docs/twitter-api/annotations/overview), if present. 16 | 17 | ## Make it yours 18 | 19 | ### Get access 20 | 21 | 1. [Sign up for the Twitter API](https://t.co/signup) (it's free!) 22 | 23 | ### Configure your Twitter app 24 | 25 | 1. Go to the [Twitter Developer Portal](https://developer.twitter.com/apps) and select the cog icon next to app you wish to use. 26 | 1. Click Edit under User authentication settings. 27 | 1. Enable OAuth 2.0. Select Single page App as your client type. 28 | 1. Configure your OAuth callback, making sure it ends with `/oauth/twitter`. For example, if you're hosting the app from your local environment, your callback will be `https://127.0.0.1:3002/oauth/twitter`. 29 | 1. Make a note of your Twitter Client ID. 30 | 31 | ### Configure your project 32 | 33 | 1. Clone this project. 34 | 1. Copy the `.env.template` file into a file named `.env` and fill out the environment variables with the client IDs and secrets for Twitter (note that you will only need the Client ID for Twitter). Your `TWITTER_REDIRECT_URI` should reflect the value of the OAuth callback. Change `APP_URL` to the URL where you are hosting your app. 35 | 1. Run `yarn` or `npm install` (only on your first run). 36 | 1. Run `yarn dev` or `npm run dev` to start the app. -------------------------------------------------------------------------------- /cert/CA.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: DES-EDE3-CBC,17328DE1490F9C7E 4 | 5 | YDV/tfqCwGUDkI/FgtiwVR5ykrkECV33AHgaiNgjtg/xSTHeyoSh4A5jAnCe5AIA 6 | LPASAEBOWhw9TrWhUc17DX1qxDh2pz1z6ByB7eJPVgPfw6Sl+lPiXc0m4GP5Jq0j 7 | CDcKNsrgaJzlNKyUoInzjUG0Rzmeh58Hh7x/NCqu/Dp5xqWHazvLpdqjp54YR/OC 8 | kK8I0P6dZ88HIX91WWGpnJnqC4k0RbetMuGeJweZcTkIOLAUSMNGG2ELD4UIJZQR 9 | HS2eC+cWt/mooq5zuxoCx1Dqtao+zLBHjwymVermZaR+X2Kd5iSF7CZCv1znrU1M 10 | sPlVe6OoZAIS2BOvEAtWfVK8n8APdYhkiItGoxiprwp07zpXoig7hoAdDAMK5Hyy 11 | i21g+iEup3yFFdhGVS+nj5bVaQIohTqAtTEGqkSK2csSMhSHRZOm4dwAnnkbY5MF 12 | p2xGnIZjhpISoOvY+QuD6S7wX8MV3LZOOAZI1dgN3IYILPVN8xsQeFpXD9PlxiKN 13 | +S+h82mWZQT4uw/vCMpf1+LdOfKdXWOg/VWwzgT04Glo1udGFvB8aIBDnEymsEYU 14 | Pwy9i16mV6+QjWNE5wRuVzLHPBv5Przquw8LhnT0swFOw2jlfLKT4oe180F3EY5f 15 | ZqoJz8LUVPZ9W867HDH5o+TpCdLjDzxcoxFDjcElf2YCPUs3yM0F9+wnYu8o7ILw 16 | LRvb8pqehLEVT5noK0wyKIUdZ0JubCmR+Jn4mv7Et+BfJ+EO6XF9s1nCW6o/trMQ 17 | 5vBRnaiJewu30yzb0cGpRz899miOCFo811K85/gIFMGtr8DCRP6ZeSmNqEr470jg 18 | j+AIazUVuE0761NimoYZvytXqE0koZiHidCOJnFLPF2L3Jp6B1dszW407SwY8SkH 19 | WBG6LuDfuWx8qKPaNkDttUhyBuzLYJyHJL3NpiXAqAH0UDgmdLV8KKKDtsSNDd5C 20 | w/v+Ar7HLG2CUCFTwdAVjHyjTjXdV6HVFzkTYn3nOmwLzw1N0yBKJal24G02xds1 21 | IQyzPe529+7lvy9DUCR7mekS4K5y7MEVoshsmxQzCNqHG7GTxU37RTM8Jp0jDqNt 22 | Bu87IUnV5TTqvEvzZMq9rFQPc7aKRVXSztC+vwySz9DKB5NqrVI47UoFWMcM9bt6 23 | r04i+i2YMw718UKRDWbOOpk+MsxBjvETHhhSBA8HTho2e2+OEusoe/KTIh2Kt66D 24 | zwK56rvFFiHYBNXp2Kg27CtEbsPKj25wsNcpTMhEhZDvkqi+qyHXMfuXSycFB5tZ 25 | ODTSHEhaHQFlS1HTReKgsFWa/K+rYbH1aMHJj7peP0CNnvJt79d3WnS+o4JgRvWu 26 | hgEh2lBH550Op0DZnIhoJIcZ5MsGGf8xqiawBofHpOh48PC9xSe9xATgWf6Muum8 27 | yyCslgfRDCBkq8+gHAyQidD0Vt2BxBj1MITmb/X2xew8sN3ynYOz467LDVTgriUk 28 | H11CcR7uHigoUVu9DYZcUX06QMvDeIPT0RSGCHozZ+t5zk66TK88bnEni15Dvg8T 29 | QTm0LZl4QxiBKbqwosRxo8M0WS1ghe/j3Db0u2opl9EE3LuSWILRn/wGz4Blnz6I 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /cert/CA.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICljCCAX4CCQDgL2gY6H+LNDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV 3 | UzAeFw0yMjAyMDIyMTExMjJaFw0zMjAxMzEyMTExMjJaMA0xCzAJBgNVBAYTAlVT 4 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA206TAjOdaxf8Nns2qoL9 5 | UcjlJ0aaTyKH+ctx3yXAnNLKAjxYD/Aq7iRzqdutiZ58zucr11xySFUSeIOIOeYQ 6 | F7qAhYbn9dCG+aEq5ZN8rR3q33pm4VYh68qD1EjnZhUTsv3MP+JsHrtfM/KbnErZ 7 | Qt/7926458k9d6P9fgS6hjOY7kUS1j3KnDTuq4GI/2FaLN8ShqiZZbw2zSnBhQf6 8 | MTFj8Ks9J85q3DfUC2WDzn90lH5Rw58K0fL9B2rLm9on+zYQ3uf9zDGorPQEv+TZ 9 | FAvKhl+QOW5GjiUl66o4NRUvnk5B09WPWgrSWeBkDWbw8xyzITtHh1n9IBC0Ckig 10 | OQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAPHRkur5wgr3GfRnrTYg4kZnpZ9jay 11 | u5QsAmHI1w/LB10ihqjmDod5jj7yw8fJhOZSGc8CqPPJsvTrBkK6y+ewFbRR4ZRA 12 | iBvKiOdwmdcnrnleiBIbZZk1U1GJW20GnLOzmgnVzgir2xa2CVxe6kghf7cymyeP 13 | 2Ttt+p3iCYt5pJj+2YBxfYrinT3Xb1g2r7rlZFwiEYl7DdQqTbLaKt1Mu1gz2/7i 14 | dAjZ120noRdsoM2biGtIojsWqsBOalWXy4FElVKC4sHndbr1KyMcBinjNlHvK1wh 15 | eqoFSBgX0vfv0Mdv2WuhGVOe22QloHA1tQsGdOgdsE/BY63sQQk1hJLp 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /cert/localhost/.srl: -------------------------------------------------------------------------------- 1 | C29B0FA5B08F8B72 2 | -------------------------------------------------------------------------------- /cert/localhost/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC/DCCAeSgAwIBAgIJAMKbD6Wwj4tyMA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNV 3 | BAYTAlVTMB4XDTIyMDIwMjIxMTMwMVoXDTMyMDEzMTIxMTMwMVowDTELMAkGA1UE 4 | BhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCprR7n3cO+D6iZ 5 | LkLwp8fgu1CFnZhiUekBDPJebRp2RHUSDtC2QUZS5bMR+29LzxBqc0oQFYUeMqWr 6 | GOX6R8bILRLmkFzV51Ebu/bmHVjov4c1/0E10m9kDfDADR4nQdIC8twUpfCHlJsL 7 | pT2jeWzO9+71BwnVcbcgOXRqiREyHu7sxNY6r9Kd3DeDopylHEciIRR1ES96Hxn3 8 | 7de6JQu5cwZ9u4GqCQW8FrxYY2wzQ4x9F21LF5g+M6UQGNWMZm4l0W6UHAihxPaR 9 | UUAKo/bra4TRPputpgVMq2ijaLCwmdpxmlhbm7jHFE6mV8wapnpor0mwrFKYfgYr 10 | ut9nqe55AgMBAAGjXzBdMCcGA1UdIwQgMB6hEaQPMA0xCzAJBgNVBAYTAlVTggkA 11 | 4C9oGOh/izQwCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwGgYDVR0RBBMwEYIJbG9j 12 | YWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQC526xB46ZSMYDl6apiU8ZF 13 | V/gRqkGf/B4DdcIIzCXmN7QKlsywKQ/xFZ3WWpPIuAc4pKe2S+gy6NnNf8HCYAat 14 | w7krBrHUgJDG/tt+TX8FkxNHc81o4cE8wSaGao9bAu+741A7tT72cX2VMFsQbm4N 15 | /J0sFWHlSBkKFO0O8zolOA2CsPZZ+U3grTZwpkoUmTgILgLwQeQGGUzihUZnUDBj 16 | m/qWzMgixxe465jP4Mm19kfM9CABuqttGCK79ESAkD+w4jYdNMb7dqw4CN192SXf 17 | 4bixq3S2eapBn44SiSJaTZnX31PGQxZ1logT9ZdKro8NG0lzIosGh51RnMJtzPg7 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /cert/localhost/localhost.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICZzCCAU8CAQAwDTELMAkGA1UEBhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IB 3 | DwAwggEKAoIBAQCprR7n3cO+D6iZLkLwp8fgu1CFnZhiUekBDPJebRp2RHUSDtC2 4 | QUZS5bMR+29LzxBqc0oQFYUeMqWrGOX6R8bILRLmkFzV51Ebu/bmHVjov4c1/0E1 5 | 0m9kDfDADR4nQdIC8twUpfCHlJsLpT2jeWzO9+71BwnVcbcgOXRqiREyHu7sxNY6 6 | r9Kd3DeDopylHEciIRR1ES96Hxn37de6JQu5cwZ9u4GqCQW8FrxYY2wzQ4x9F21L 7 | F5g+M6UQGNWMZm4l0W6UHAihxPaRUUAKo/bra4TRPputpgVMq2ijaLCwmdpxmlhb 8 | m7jHFE6mV8wapnpor0mwrFKYfgYrut9nqe55AgMBAAGgFTATBgkqhkiG9w0BCQcx 9 | BgwEcGFzczANBgkqhkiG9w0BAQsFAAOCAQEAWfiB5mt99nBK5ijTsQtk3XteSndB 10 | OzJyjsjiYSEfeWFULxyRZ0FctBB4/mZ1/IBS23yaYLniKzfSBEoSApUVDOKkXhTw 11 | 0iUWsi+W6SBN3S10BWg9FVIn4A/5P3O/RBKEhdraJej79uBbnfNYFyIFNHKsNZH2 12 | o2jvYFWYZ3TmYA1QrUAXmW9hqJvLKY6/yfzMfoDCt+hgAZ5yJpb4cE3LudhXmW/A 13 | yCUGhjz38vtHufuxnLr8VSxZtgAwXtfxDpThTOF5rjej3ZGCJz9ayXNbrkIugS9u 14 | o2qrqtLjFGBVJ9ZD6wIr6c6A8tnB+sFjwSSE9+5dduHB4PEY/Ugk/B14Ww== 15 | -----END CERTIFICATE REQUEST----- 16 | -------------------------------------------------------------------------------- /cert/localhost/localhost.decrypted.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAqa0e593Dvg+omS5C8KfH4LtQhZ2YYlHpAQzyXm0adkR1Eg7Q 3 | tkFGUuWzEftvS88QanNKEBWFHjKlqxjl+kfGyC0S5pBc1edRG7v25h1Y6L+HNf9B 4 | NdJvZA3wwA0eJ0HSAvLcFKXwh5SbC6U9o3lszvfu9QcJ1XG3IDl0aokRMh7u7MTW 5 | Oq/Sndw3g6KcpRxHIiEUdREveh8Z9+3XuiULuXMGfbuBqgkFvBa8WGNsM0OMfRdt 6 | SxeYPjOlEBjVjGZuJdFulBwIocT2kVFACqP262uE0T6braYFTKtoo2iwsJnacZpY 7 | W5u4xxROplfMGqZ6aK9JsKxSmH4GK7rfZ6nueQIDAQABAoIBAG9XL+mc5z6Hze2+ 8 | oqSCpLF+mScktAMIlupekYiO9YdYAq531FNUYA4ztp79LBpF5RLFVntZc8HOIFI3 9 | A2qJwRJIKZfscU7X/KxDWo/EADyyucogFq13yiqQz0NnGDtQgLv4m5xdC4ocZOVu 10 | aBUmr9TccwyMstJCTZWyvgbOrN8CxXO2VyORxksfVTIdd2j+K7uZrGEtqRb7Znu2 11 | 4xTiUE8hTUkkKseHxbnf8GANHdpI1lYBq+HFQMmMYaVhia+9t6gCgrRIRDmy4End 12 | Drybz/0BSLGyVyZ42jpOb6DN3LiELaNVbj5qYPcMMfqA/KcVIAQ7OO2/iVcWsWts 13 | hhmQzRUCgYEA3J0HntP1giMgQEY8Gso0IrggcwfQCKpafagoCReZzPv0J/W6zyGf 14 | h/S3K+HP/neQ4K5NzMJ3mpKQCxOrP5ltaWSLdUveYh6b0EJ6As3tZ96NIQdqU0Tw 15 | 7MrayJkqYicu9v1qMMQQAb0sdeohC2+kZnHtkreJfNes4rM0jzNjqLMCgYEAxOR6 16 | F/QElpYMQtsFQ+PEXhkYeL2EDCtD+U41+hd6cfSXTZJ0AOc5lOnZEkCHDCJ8mSKA 17 | ca675PASR9tSz0XbaDEt0CTGNF4gtvBL4XP2tWGkD2VN3Rlc0Ur3xu8ksdnaMWWP 18 | yfGJU15VTO+CY03Od3Z3cNTheovzMQTHYVRxqiMCgYBXB+O/tqJ9ylmowYxojShw 19 | Ie87MfPR11KHi9TvcU4NXb/+G6SsnfkPa2zvdM/W9VhEKr8qbfU9F2CX3hSKrH5L 20 | O40AowOB9c1GJVN408A6X8ORKhm569KXt5cD19iujAKtEV/ZCR+/b9+gTNLobgyP 21 | FZbBcIJeq77aWBP+AinonQKBgDwPEasy1R92H8FY++8skB6/+vYBoUDxBagLkm16 22 | MfCG0oxoCxinb6ob9woZQtiRwH4ZxyJmUYxRKtJedZEiVv4eWkIupYMd307OV+cq 23 | r2u+oAPOPAUgkm6JNrGpCwFxWZxNxaGtGt2iXdS9DoHqHvvT2DVDyo/OFt2x/nuS 24 | /aUPAoGBALdG+StboD80OQzAgQ3TFy8OVoMCy1ceHY5iKDm5r9x+GyWZSDCCT8DO 25 | 5Nyz99ueeUl8JxfCXWPmfI2UwOvicByKZQyI/rU9oDXavHU0aOcVGU2RmaRkSCTE 26 | hT/+OK8xwFBf8gtVL207KDif1OImw+xMcu/cBR7LoUN7mXo2XOKA 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /cert/localhost/localhost.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier = keyid,issuer 2 | basicConstraints = CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = localhost 8 | IP.1 = 127.0.0.1 9 | -------------------------------------------------------------------------------- /cert/localhost/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: DES-EDE3-CBC,88C10B9B5453E6C0 4 | 5 | CZK67+Yc6exiB+3XniBvzqeT5SSQNeCtxKpBDUT5zreFdtcTyLIZA+h0k6n/hjmN 6 | rjanPviMc0dVu4cmb4ZN/DpgVbXJmQVBa2IFVhnP2TJOBkVxV62Iq1Ef/dnWcdz+ 7 | dPSaYlOPtR3Chy4r6IoW0cW+vPkBcyIN9tF5D8vU2HaE+bcsgUpLLQx8t9yI3H+5 8 | PNontbH/4iteULODoMZipJsBIqUTECibcz0VPYqKuHJffqKg8eSkMqDuTVdYqLyQ 9 | fF//AAjWD4yrrpe9pMqT1FZOM1yrfHMF+88AGiEewR6EdrRdQL/Ho7VU6yt17ISx 10 | c9j97ohqk6p/6y2ShYqCMuj4rsQ2Ttahe/R7g2+Ex4k8QKAGbgWbBsFgfCS4JyFR 11 | nJTBzzc4yAvwWUP+kSeUNgi8ioc/q/5pw2S0UQAn37bjDN6ukmgqafWqn680l0qA 12 | cAQF3bpmV7LAbW+jFV+qd8kbpVxKh7kz8dehXWDBTmcj/s/nRwQEYLbjGqjXNMDv 13 | BjNYl7JXVd7wGICmSbHLQ59IS6STrNO+5o3Z4L2GsdnwtS5OK2Xl70qal9HkN4Vb 14 | eepWUnHX7gS2T6OaNTTsrkV3FyoL7N6YEY/mnc7XELD6Je4V+SU7NeoaGylH/pcj 15 | 5AkJJxnsVObhLsO30cX5rg4m5lXRc/p3KAYUYXRVyqbpnLbojMpCfZnQ1pHbuDkY 16 | Sv8G4eN1vcp1Iv8fGO7SE75IaNPwaAgN144AYGocOrpjRIS62sl2oR/3hUCzR8CB 17 | p0tLkL/qUuHJ5toA25WLpkRcXK++84p77Bzpzmk0UtiVnVvazTB0kG1PW+nzo+VA 18 | tqKqAkM0CqCd49fIWyMeVLyJ3f29SbktVarfzw3UF91/h2TPZeVmwq5MRa21PPi8 19 | 3y2NyKFVaXnhG7zUtwlcVbH6e1CG7Wv6Q2KoIQ9TvsYmn9Zg2nCwsHTTqDZldBbl 20 | cwu52qh5KCmn3+7kMWrx29jh/W1jw0IKcwbseUUa/K1z9kQpwm5/t9Q2rDTVQwtH 21 | Re9kWa6oENVYb/IjVdCPlGfW7ix3ZgApvozCIgNN2ucd3MvYHaAGipwf0OU+Mi1S 22 | tOvUuW5imNnFXhheStTzPnnRQ7h7sU30EgWkBAFW+kU5ELasA4K1o2CURsjOuOEn 23 | pLaDKBWEYuEWBCUD1vwqtBUndvsACe1C+aeFwVoJTJ2A5jgRsPTZEDoQ9as+IJpI 24 | NwryFWrFl0k6oLje/MLyCS5xCF0Z4EorLA060/1mvgl/oUkcE+oiYYObn4LwDoXy 25 | YSpx4+CDD0wwXVicYYkm2gHQv6H8BF4X04uNFHWD9mQ8Bx94Y37W0WIqigHZgz5k 26 | 6AO3CvMk/KTuz2ZNFhv7DvZ/H8Oa2cIE8XCs0JypjDUPjtgVNbNmpIep6sUcBpwq 27 | +38Dpe6OgqAgn37f6xUWpRU4ch3FB1rIcYY26yrlUZuNhwFMiOGQ/AaRtq6x0pCi 28 | Y7Zl44JRytVJlNORlFXZIbsFpNZEXbFp8OfnybFKRnVnx6owhyRC9HBcJnVNjkFO 29 | uv1d3UaedPnI36sAkqFcs6HaaYMjrEjDuRWe8xO2BZm/jJgA4FYWHQ== 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | module.exports = function override(config, env) { 2 | return { 3 | ...config, 4 | experiments: { 5 | topLevelAwait: true 6 | } 7 | }; 8 | }; -------------------------------------------------------------------------------- /deploy.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const { existsSync } = require('fs'); 3 | const { argv, env, exit, cwd, chdir } = process; 4 | const simpleGit = require('simple-git'); 5 | 6 | const ErrorCode = { 7 | MISSING_PARAMETERS: -1, 8 | PATH_NOT_FOUND: -2, 9 | REMOTE_NOT_FOUND: -3, 10 | CANNOT_DEPLOY_TO_ORIGIN: -4, 11 | NO_NPM_EXECPATH: -5, 12 | EXEC_FAILED: -6, 13 | GIT_FAILED: -7, 14 | GIT_NOT_CLEAN: -8, 15 | UNKNOWN: -254 16 | }; 17 | 18 | if (!env.npm_execpath) { 19 | console.error('❌ use yarn deploy or npm run deploy'); 20 | exit(ErrorCode.NO_NPM_EXECPATH); 21 | } 22 | 23 | const npm = env.npm_execpath.match('yarn') ? 'yarn' : 'npm run'; 24 | 25 | 26 | const printUsage = () => { 27 | const command = env.npm_execpath.match('yarn') ? `${npm} deploy` : `${npm} run deploy`; 28 | console.error(`usage: ${command} git-remote`); 29 | } 30 | 31 | const run = async () => { 32 | 33 | const git = simpleGit(); 34 | const [,, remote] = argv; 35 | 36 | if (!remote) { 37 | printUsage(); 38 | exit(ErrorCode.MISSING_PARAMETERS); 39 | } 40 | 41 | if (remote.toLowerCase() === 'origin') { 42 | console.error('❌ cannot deploy to origin'); 43 | exit(ErrorCode.CANNOT_DEPLOY_TO_ORIGIN); 44 | } 45 | 46 | const remotes = await git.getRemotes(); 47 | if (!remotes.find(({name}) => name === remote)) { 48 | console.error('❌ remote not found:', remote); 49 | exit(ErrorCode.REMOTE_NOT_FOUND); 50 | } 51 | 52 | const branchTime = new Date(); 53 | const currentDir = cwd(); 54 | const branchName = `deploy-${branchTime.getTime()}`; 55 | 56 | const status = await git.status(); 57 | 58 | if (!status.isClean()) { 59 | console.error('❌ your branch is not clean. please ensure that your changes are committed, your working directory is clean, and there are no conflicts or untracked files.'); 60 | exit(ErrorCode.GIT_NOT_CLEAN); 61 | } 62 | 63 | try { 64 | // 1. build (yarn build) 65 | console.error('Generating build…'); 66 | execSync(`${npm} build`); 67 | } catch(e) { 68 | console.error(e); 69 | exit(ErrorCode.EXEC_FAILED); 70 | } 71 | 72 | try { 73 | // 3. create branch (git checkout; git add; git commit; 74 | // 4. git push remote main --force) 75 | // delete branch 76 | console.error('Deploying…') 77 | await git.checkoutLocalBranch(branchName); 78 | await git.add('build/*'); 79 | await git.commit(`Deploy ${branchTime.toUTCString()}`); 80 | await git.push([remote, `${branchName}:master`, '--force']); 81 | await git.checkout('main'); 82 | await git.deleteLocalBranch(branchName, true); 83 | console.error('✅ Done'); 84 | exit(); 85 | 86 | } catch(e) { 87 | console.error(e); 88 | await git.checkout('main'); 89 | await git.deleteLocalBranch(branchName, true); 90 | chdir(currentDir); 91 | exit(ErrorCode.GIT_FAILED); 92 | } 93 | } 94 | 95 | (async () => { 96 | await run(); 97 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookmark-search", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "server.js", 6 | "proxy": "https://127.0.0.1:3002", 7 | "engines": { 8 | "node": "14.x" 9 | }, 10 | "dependencies": { 11 | "@emotion/react": "^11.8.2", 12 | "@emotion/styled": "^11.8.1", 13 | "@mui/icons-material": "^5.5.1", 14 | "@mui/lab": "^5.0.0-alpha.74", 15 | "@mui/material": "^5.5.2", 16 | "@testing-library/jest-dom": "^5.16.2", 17 | "@testing-library/react": "^12.1.4", 18 | "@testing-library/user-event": "^13.5.0", 19 | "add": "^2.0.6", 20 | "cookie-parser": "^1.4.5", 21 | "dotenv": "^10.0.0", 22 | "dotenv-webpack": "^7.1.0", 23 | "express": "^4.17.1", 24 | "express-handlebars": "^5.3.2", 25 | "http-proxy-middleware": "^2.0.4", 26 | "node-fetch": "^2.6.1", 27 | "react": "^17.0.2", 28 | "react-app-rewired": "^2.2.1", 29 | "react-dom": "^17.0.2", 30 | "react-scripts": "5.0.0", 31 | "react-twitter-widgets": "^1.10.0", 32 | "react-window": "^1.8.6", 33 | "simple-git": "^3.2.6", 34 | "web-vitals": "^2.1.4", 35 | "yarn": "^1.22.18" 36 | }, 37 | "devDependencies": { 38 | "cors": "^2.8.5", 39 | "nodemon": "^2.0.15" 40 | }, 41 | "scripts": { 42 | "start": "node server.js", 43 | "dev": "nodemon server.js & react-app-rewired start && kill $!", 44 | "frontend": "react-app-rewired start", 45 | "build": "react-app-rewired build", 46 | "test": "react-app-rewired test", 47 | "eject": "react-scripts eject", 48 | "deploy": "node deploy.js" 49 | }, 50 | "eslintConfig": { 51 | "extends": [ 52 | "react-app", 53 | "react-app/jest" 54 | ] 55 | }, 56 | "browserslist": { 57 | "production": [ 58 | ">0.2%", 59 | "not dead", 60 | "not op_mini all" 61 | ], 62 | "development": [ 63 | "last 1 chrome version", 64 | "last 1 firefox version", 65 | "last 1 safari version" 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdevplatform/bookmarks-search/a54865f7e4c1782e1d96e92512ada258bb84a56f/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Bookmark Search 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdevplatform/bookmarks-search/a54865f7e4c1782e1d96e92512ada258bb84a56f/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdevplatform/bookmarks-search/a54865f7e4c1782e1d96e92512ada258bb84a56f/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const express = require('express'); 3 | const app = express(); 4 | require('dotenv').config() 5 | 6 | let server; 7 | if (typeof process.env.ENVIRONMENT === 'undefined' || process.env.ENVIRONMENT !== 'production') { 8 | const key = fs.readFileSync('./cert/localhost/localhost.decrypted.key'); 9 | const cert = fs.readFileSync('./cert/localhost/localhost.crt'); 10 | server = require('https').Server({ key, cert }, app); 11 | } else { 12 | server = require('http').Server(app); 13 | } 14 | 15 | const cookieParser = require('cookie-parser'); 16 | const Twitter = require('./twitter-oauth'); 17 | const fetch = require('node-fetch'); 18 | const res = require('express/lib/response'); 19 | 20 | require('dotenv').config(); 21 | 22 | app.use(express.static('build')); 23 | app.use(express.json()); 24 | app.use(cookieParser(process.env.COOKIE_SECRET)); 25 | app.use(express.json()); 26 | 27 | app.get('/oauth/:service', async (request, response) => { 28 | let service = {}; 29 | switch (request.params.service) { 30 | case 'twitter': 31 | service = { 32 | stateKey: 'twitter_state', 33 | redirectUriKey: 'twitter_redirect_uri', 34 | tokenKey: 'token', 35 | provider: Twitter, 36 | callback: process.env.TWITTER_REDIRECT_URI, 37 | }; 38 | break; 39 | default: 40 | return response.status(400).json({error: 'Invalid service.'}); 41 | } 42 | if (request.query.state && request.query.code) { 43 | // exchange token for code 44 | const tokenData = await service.provider.exchangeToken(request.query.code, service.callback); 45 | tokenData.expires_at = new Date().getTime() + (tokenData.expires_in * 1000); 46 | response.clearCookie(service.stateKey); 47 | response.clearCookie(service.redirectUriKey); 48 | response.cookie(service.tokenKey, tokenData); 49 | 50 | try { 51 | const url = new URL(process.env.APP_URL); 52 | response.redirect(url.href); 53 | } catch (e) { 54 | console.error(e); 55 | const url = new URL(process.env.APP_URL); 56 | url.searchParams.set('error', '1'); 57 | response.redirect(url.href); 58 | } 59 | } 60 | }); 61 | 62 | app.get('/oauth/:service/refresh', async (request, response) => { 63 | let service = {}; 64 | switch (request.params.service) { 65 | case 'twitter': 66 | service = { 67 | tokenKey: 'token', 68 | provider: Twitter 69 | }; 70 | break; 71 | default: 72 | return response.status(400).json({error: 'Invalid service.'}); 73 | } 74 | 75 | if (!request.cookies[service.tokenKey]) { 76 | return response.status(400).json({error: 'Could not find a token to refresh in your browser session.'}); 77 | } 78 | 79 | const tokenData = await service.provider.refreshToken(request.cookies[service.tokenKey].refresh_token); 80 | if (tokenData.error) { 81 | response.status(400).json({error: tokenData.error}); 82 | return; 83 | } 84 | 85 | tokenData.expires_at = new Date().getTime() + (tokenData.expires_in * 1000); 86 | response.cookie(service.tokenKey, tokenData); 87 | response.json({refresh: true, token: tokenData}); 88 | }); 89 | 90 | app.get('/', (request, response) => { 91 | response.sendFile(__dirname + '/build/index.html'); 92 | }); 93 | 94 | 95 | app.post('/request', async (request, response) => { 96 | if (!request.body) { 97 | return response.status(400).json({error: 'Missing body.'}); 98 | } 99 | 100 | if (!request.body.url.match(/^https\:\/\/api.twitter.com\//)) { 101 | return response.status(400).json({error: 'Invalid URL.'}); 102 | } 103 | 104 | if (!request.cookies.token) { 105 | return response.status(400).json({error: 'No access token.'}); 106 | } 107 | 108 | const options = { 109 | method: request.body.method, 110 | headers: { 111 | 'Authorization': `Bearer ${request.cookies.token.access_token}`, 112 | 'User-agent': 'TwitterDevBookmarkSearch', 113 | }, 114 | }; 115 | 116 | if (process.env.TWITTER_HEADERS) { 117 | try { 118 | twitterHeaders = JSON.parse(process.env.TWITTER_HEADERS); 119 | options.headers = Object.assign(options.headers, twitterHeaders); 120 | } catch (e) { 121 | console.log('Cannot parse Twitter headers:', e.message); 122 | } 123 | } 124 | 125 | if (request.body.method === 'PUT' || request.body.method === 'POST' && request.body.body) { 126 | options.headers['Content-Type'] = 'application/json'; 127 | options.body = JSON.stringify(request.body.body); 128 | } 129 | 130 | let r; 131 | try { 132 | r = await fetch(request.body.url, options); 133 | } catch (e) { 134 | console.error(e); 135 | response.status(400).json({error: e}); 136 | } 137 | 138 | try { 139 | const json = await r.json(); 140 | const rateLimitHeaders = {}; 141 | for (const [name, value] of r.headers.entries()) { 142 | if (name.includes('x-rate-limit')) { 143 | rateLimitHeaders[name] = value; 144 | } 145 | } 146 | response.status(200).json({response: json, status: r.status, headers: rateLimitHeaders}); 147 | } catch (e) { 148 | console.error(e); 149 | response.status(400).json({error: 'The response is not valid JSON.'}); 150 | } 151 | 152 | 153 | }); 154 | 155 | app.get('/authorize/:service', async (request, response) => { 156 | let service; 157 | switch (request.params.service) { 158 | case 'twitter': 159 | service = { 160 | provider: Twitter, 161 | scope: 'tweet.read users.read bookmark.read offline.access', 162 | stateKey: 'twitter_state', 163 | } 164 | break; 165 | } 166 | const state = new Date().getTime() * (1 + Math.random()); 167 | response.cookie(service.stateKey, state); 168 | 169 | const url = service.provider.authorizeURI(state, service.scope); 170 | response.redirect(url.toString()); 171 | 172 | }); 173 | 174 | app.get('/oauth/twitter/revoke', async (request, response) => { 175 | if (!request.cookies.token) { 176 | response.json({revoked: true}); 177 | } else { 178 | const r = await revokeToken(request.cookies.token.access_token); 179 | response.clearCookie('token'); 180 | response.json(r); 181 | } 182 | 183 | }); 184 | 185 | const listener = server.listen(process.env.PORT || 3002, async () => { 186 | console.log(`Your app is listening on port ${listener.address().port}`); 187 | }); 188 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import React from 'react'; 3 | import Cookies from './cookies'; 4 | import { styled, alpha, Button, Container, Grid, Link, List, ListItem, Snackbar, Stack, Typography, Alert } from '@mui/material'; 5 | import InputBase from '@mui/material/InputBase'; 6 | import SearchIcon from '@mui/icons-material/Search'; 7 | import Loading from './Loading'; 8 | import Tweet from './Tweet'; 9 | import { userLookup } from './utils'; 10 | import FolderList from './FolderList'; 11 | 12 | const request = async (url, method = 'GET', body = '') => { 13 | return await fetch('/request', { 14 | method: 'POST', 15 | headers: { 16 | 'Accept': 'application/json', 17 | 'Content-type': 'application/json' 18 | }, 19 | body: JSON.stringify({ 20 | url: url.toString(), 21 | method: method, 22 | body: body 23 | }) 24 | }); 25 | } 26 | 27 | const paginatedRequest = async (url, method = 'GET', body = '') => { 28 | let out = {data: [], includes: {users: []}, meta: {}}; 29 | let i = 0; 30 | do { 31 | i++; 32 | 33 | if (i > 5) { 34 | break; 35 | } 36 | 37 | if (out.meta.next_token) { 38 | url.searchParams.set('paginationToken', out.response?.meta.next_token); 39 | } 40 | 41 | const response = await request(url, method, body); 42 | const json = await response.json(); 43 | 44 | if (!(response.ok && json.response.data)) { 45 | break; 46 | } 47 | 48 | out.data = [...out.data, ...json.response.data]; 49 | out.includes.users = [...out.includes.users, ...json.response.includes?.users ?? []]; 50 | out.meta = json.response.meta ?? out.meta; 51 | 52 | if (!json.response.meta) { 53 | out.meta.next_token = null; 54 | } 55 | } while (out.meta.next_token); 56 | 57 | return out; 58 | } 59 | 60 | const Search = styled('div')(({ theme }) => ({ 61 | position: 'relative', 62 | borderRadius: theme.shape.borderRadius, 63 | backgroundColor: alpha(theme.palette.common.white, 0.15), 64 | '&:hover': { 65 | backgroundColor: alpha(theme.palette.common.white, 0.25), 66 | }, 67 | width: '100%', 68 | [theme.breakpoints.up('sm')]: { 69 | marginLeft: theme.spacing(3), 70 | width: 'auto', 71 | }, 72 | })); 73 | 74 | const SearchIconWrapper = styled('div')(({ theme }) => ({ 75 | padding: theme.spacing(0, 2), 76 | height: '100%', 77 | position: 'absolute', 78 | pointerEvents: 'none', 79 | display: 'flex', 80 | alignItems: 'center', 81 | justifyContent: 'center', 82 | })); 83 | 84 | const StyledInputBase = styled(InputBase)(({ theme }) => ({ 85 | color: 'inherit', 86 | '& .MuiInputBase-input': { 87 | padding: theme.spacing(1, 1, 1, 0), 88 | // vertical padding + font size from searchIcon 89 | paddingLeft: `calc(1em + ${theme.spacing(4)})`, 90 | transition: theme.transitions.create('width'), 91 | width: '100%', 92 | [theme.breakpoints.up('md')]: { 93 | width: '20ch', 94 | }, 95 | }, 96 | })); 97 | 98 | 99 | const hasValidToken = () => { 100 | const token = Cookies.get('token'); 101 | if (!token) { 102 | return false; 103 | } 104 | 105 | const tokenExpiration = new Date(token.expires_at); 106 | if (tokenExpiration < new Date()) { 107 | return false; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | export default class App extends React.Component { 114 | delay = null; 115 | state = { 116 | error: false, 117 | loading: true, 118 | tweets: {data: [], includes: [], meta: {}}, 119 | results: {data: [], includes: [], meta: {}}, 120 | folderLabel: null, 121 | folderValues: null, 122 | }; 123 | 124 | constructor(props) { 125 | super(props); 126 | this.searchRef = React.createRef(); 127 | } 128 | 129 | async componentDidMount() { 130 | try { 131 | const myUser = await request('https://api.twitter.com/2/users/me'); 132 | const myUserResponse = await myUser.json(); 133 | const { id } = myUserResponse.response.data; 134 | 135 | const myBookmarksURL = new URL(`https://api.twitter.com/2/users/${id}/bookmarks`); 136 | myBookmarksURL.searchParams.append('tweet.fields', 'context_annotations,created_at'); 137 | myBookmarksURL.searchParams.append('expansions', 'author_id'); 138 | myBookmarksURL.searchParams.append('user.fields', 'verified,profile_image_url'); 139 | 140 | const myBookmarks = await paginatedRequest(myBookmarksURL); 141 | this.setState({loading: false, tweets: myBookmarks, results: myBookmarks}); 142 | this.searchRef.current.querySelector('input').focus() 143 | } catch (e) { 144 | console.error(e); 145 | this.setState({error: true, loading: false}); 146 | } 147 | } 148 | 149 | async retry() { 150 | this.setState({error: false}); 151 | await this.request(); 152 | } 153 | 154 | search(e) { 155 | clearTimeout(this.delay); 156 | setTimeout(() => { 157 | if (!e.target.value) { 158 | this.setState({results: this.state.tweets}); 159 | return; 160 | } 161 | 162 | const value = e.target.value.toLowerCase(); 163 | 164 | const dataset = this.state.folderValues ? this.getResultsForContexts(this.state.folderValues) : this.state.tweets.data; 165 | const results = this.state.tweets.data.filter(tweet => { 166 | const annotations = tweet.context_annotations?.map(({entity}) => entity.name) ?? []; 167 | const user = userLookup(tweet.author_id, this.state.tweets); 168 | const result = [ 169 | tweet.text, 170 | user.name, 171 | user.username, 172 | ...annotations 173 | ].find(token => token.toLowerCase().match(value)); 174 | 175 | if (result) { 176 | return tweet; 177 | } 178 | }); 179 | 180 | this.setState({results: {data: results, includes: this.state.tweets.includes, meta: this.state.tweets.meta}}); 181 | }, 500); 182 | } 183 | 184 | getResultsForContexts(values) { 185 | return this.state.tweets.data 186 | .filter(({context_annotations = []}) => { 187 | const contexts = context_annotations.map(({domain, entity}) => `${domain.id}.${entity.id}`) 188 | return contexts.filter(value => values.includes(value)).length > 0 189 | }); 190 | } 191 | 192 | didSelectFolder(label, values) { 193 | this.searchRef.current.querySelector('input').value = ''; 194 | if (values === null) { 195 | this.setState({results: this.state.tweets, folderLabel: null, folderValues: null}); 196 | } else { 197 | const results = this.getResultsForContexts(values); 198 | this.setState({results: {data: results, includes: this.state.tweets.includes, meta: this.state.tweets.meta}, folderLabel: label, folderValues: values}); 199 | } 200 | } 201 | 202 | render() { 203 | if (!hasValidToken()) { 204 | return 209 | 210 | 211 | Bookmark Search 212 | Finally! Search your Twitter bookmarks. 213 | Authorize Twitter so can I read your Bookmarks. 214 | This app will only be able to read your bookmarks. It will never Tweet on your behalf. 215 | 216 | 217 | 218 | 219 | 220 | Made with 💙 by the @TwitterDev team. 221 | Check out the Source code on GitHub or Remix this app 222 | 223 | ; 224 | } 225 | 226 | if (this.state.loading) { 227 | return 230 | 231 | 232 | } 233 | 234 | return 235 | 237 | 238 | This app is open source. Check out its code to build your own version! 239 |

240 | Check out the code on GitHub 241 |
242 | Sign up for the Twitter API 243 |
244 |
245 | await this.retry()}> 250 | Retry 251 | 252 | }> 253 | 254 | 255 | 256 | 257 | 258 | Smart folders 259 | 260 | 261 | These folders are automatically created based on Twitter's machine learning interpretation of a Tweet. 262 | 263 | this.didSelectFolder(label, value)} /> 264 | 265 | 266 | 267 | 268 | 269 | 270 | this.search(e)} 273 | placeholder={this.state.folderLabel ? `Search ${this.state.folderLabel}` : 'Search all bookmarks'} 274 | inputProps={{ 'aria-label': 'search' }} 275 | /> 276 | 277 | 278 | {this.state.results && this.state.results.data.length === 1 ? '1 bookmark' : `${this.state.results.data.length} bookmarks`} 279 | 280 | 283 | {this.state.results && this.state.results?.data.length === 0 ? No bookmarks : 284 | this.state.results.data.map(tweet => )} 285 | 286 | 287 | 288 | 289 |
; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/FolderList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Chip, Stack } from "@mui/material"; 3 | 4 | export default class FolderList extends React.Component { 5 | state = {selectedItem: null, folders: null} 6 | 7 | constructor(props) { 8 | super(props) 9 | } 10 | 11 | changeFolder(value) { 12 | this.setState({selectedItem: value}); 13 | if (this.props.onFolderSelect) { 14 | 15 | if (value === null) { 16 | this.props.onFolderSelect(null, null); 17 | } else { 18 | this.props.onFolderSelect(value, this.state.folders[value]); 19 | } 20 | } 21 | } 22 | 23 | componentDidMount() { 24 | if (this.state.folders) { 25 | return; 26 | } 27 | const list = this.props.tweets 28 | .filter(({context_annotations = null}) => context_annotations) 29 | .map(({context_annotations}) => { 30 | return context_annotations.map(({domain, entity}) => {return {name: entity.name, value: `${domain.id}.${entity.id}`}}); 31 | }) 32 | .flat(); 33 | 34 | const folders = {}; 35 | for (const {name, value} of list) { 36 | if (!folders[name]) { 37 | folders[name] = []; 38 | } 39 | 40 | folders[name].push(value); 41 | } 42 | 43 | this.setState({folders}); 44 | } 45 | 46 | renderFolders() { 47 | if (!this.state.folders) { 48 | return <>; 49 | } 50 | 51 | return Object.keys(this.state.folders).map(key => 52 | this.changeFolder(key)} variant={this.state.selectedItem === key ? 'filled' : 'outlined'} label={key} /> 53 | ); 54 | } 55 | 56 | render() { 57 | return 58 | this.changeFolder(null)} variant={this.state.selectedItem === null ? 'filled' : 'outlined'} label='All Tweets' /> 59 | {this.renderFolders()} 60 | ; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Loading.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Skeleton, Stack } from "@mui/material"; 3 | export default class Loading extends React.Component { 4 | render() { 5 | return [...Array(3).keys()].map((i) => 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | } -------------------------------------------------------------------------------- /src/Tweet.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar, Chip, Grid, Link, Paper, Typography } from "@mui/material"; 3 | import { Verified } from "@mui/icons-material"; 4 | import { userLookup } from './utils'; 5 | 6 | export default class Tweet extends React.Component { 7 | render() { 8 | const user = userLookup(this.props.tweet.author_id, this.props.response); 9 | 10 | return 11 | 20 | theme.palette.mode === 'dark' ? '#1A2027' : '#fff', 21 | }} 22 | > 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {user.name} {user.verified ? : <>} 32 | 33 | 34 | 35 | @{user.username} 36 | 37 | 38 | {this.props.tweet.text} 39 | 40 | 41 | 42 | 43 | 44 | {new Date(this.props.tweet.created_at).toLocaleString()} 45 | 46 | 47 | {this.props.tweet.context_annotations ? 48 | this.props.tweet.context_annotations.map(({entity}) => ) : 49 | <>} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/cookies.js: -------------------------------------------------------------------------------- 1 | const Cookies = { 2 | get (key) { 3 | let cookie = document.cookie.split('; ').find(cookie => cookie.startsWith(key + '=')); 4 | if (!cookie) { 5 | return; 6 | } 7 | 8 | const value = decodeURIComponent(cookie.replace(key + '=', '')); 9 | if (value.startsWith('j:')) { 10 | return JSON.parse(value.slice(2)); 11 | } else { 12 | return value; 13 | } 14 | }, 15 | 16 | remove(key) { 17 | document.cookie.split(';') 18 | .forEach(c => { 19 | document.cookie = c.replace(/^ +/, '') 20 | .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/'); 21 | }); 22 | 23 | 24 | } 25 | }; 26 | 27 | export default Cookies; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | #root { 8 | height: 100%; 9 | display: flex; 10 | justify-content: center; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 16 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 17 | sans-serif; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | 22 | code { 23 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 24 | monospace; 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const userLookup = (userId, tweets) => tweets.includes.users.find(user => user.id === userId); -------------------------------------------------------------------------------- /twitter-oauth/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | const authorizeURI = (state, scope = 'tweet.read users.read') => { 4 | const url = new URL('https://twitter.com/i/oauth2/authorize'); 5 | url.searchParams.append('response_type', 'code'); 6 | url.searchParams.append('client_id', process.env.TWITTER_CLIENT_ID); 7 | url.searchParams.append('redirect_uri', process.env.TWITTER_REDIRECT_URI); 8 | url.searchParams.append('scope', scope); 9 | // We implement the PCKE extension for additional security. 10 | // Here, we're passing a randomly generate state parameter, along 11 | // with a code challenge. In this example, the code challenge is 12 | // a plain string, but s256 is also supported. 13 | url.searchParams.append('state', state); 14 | url.searchParams.append('code_challenge', 'challenge'); 15 | url.searchParams.append('code_challenge_method', 'plain'); 16 | return url; 17 | } 18 | 19 | const exchangeToken = async (code, callback = process.env.REDIRECT_URI) => { 20 | const url = 'https://api.twitter.com/2/oauth2/token'; 21 | const params = new URLSearchParams(); 22 | params.append('grant_type', 'authorization_code'); 23 | params.append('client_id', process.env.TWITTER_CLIENT_ID); 24 | params.append('redirect_uri', callback); 25 | params.append('code_verifier', 'challenge'); 26 | params.append('code', code); 27 | 28 | const response = await fetch(url, {method: 'POST', body: params}); 29 | const json = await response.json(); 30 | return json; 31 | } 32 | 33 | const refreshToken = async (token, callback = process.env.REDIRECT_URI) => { 34 | const url = 'https://api.twitter.com/2/oauth2/token'; 35 | const params = new URLSearchParams(); 36 | params.append('grant_type', 'refresh_token'); 37 | params.append('client_id', process.env.TWITTER_CLIENT_ID); 38 | params.append('redirect_uri', callback); 39 | params.append('refresh_token', token); 40 | 41 | const response = await fetch(url, {method: 'POST', body: params}); 42 | const json = await response.json(); 43 | return json; 44 | } 45 | 46 | const revokeToken = async (token) => { 47 | const url = 'https://api.twitter.com/2/oauth2/revoke'; 48 | const params = new URLSearchParams(); 49 | params.append('client_id', process.env.TWITTER_CLIENT_ID); 50 | params.append('token', token); 51 | params.append('token_type_hint', 'access_token'); 52 | 53 | const response = await fetch(url, {method: 'POST', body: params}); 54 | const json = await response.json(); 55 | console.log(json); 56 | return json; 57 | } 58 | 59 | module.exports = { authorizeURI, exchangeToken, refreshToken, revokeToken }; --------------------------------------------------------------------------------