├── .gitignore ├── README.md ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── api │ ├── .gitignore │ ├── .prettierrc │ ├── .sequelizerc │ ├── LICENSE │ ├── README.md │ ├── app.js │ ├── bin │ │ └── www │ ├── config │ │ ├── config.js │ │ └── load-config.js │ ├── db.js │ ├── jest.config.js │ ├── middleware │ │ └── session.js │ ├── migrations │ │ ├── 20210418232436-create-user.js │ │ ├── 20210418232915-create-ballot.js │ │ └── 20210524193721-uniq-ballots.js │ ├── models │ │ ├── ballot.js │ │ ├── index.js │ │ └── user.js │ ├── package.json │ ├── public │ │ ├── api.json │ │ ├── index.html │ │ ├── javascripts │ │ │ ├── swagger-ui-bundle.js │ │ │ ├── swagger-ui-bundle.js.map │ │ │ ├── swagger-ui-es-bundle-core.js.map │ │ │ ├── swagger-ui-es-bundle.js.map │ │ │ ├── swagger-ui-standalone-preset.js │ │ │ ├── swagger-ui-standalone-preset.js.map │ │ │ └── swagger-ui.js.map │ │ ├── oauth2-redirect.html │ │ ├── question-circle-solid.svg │ │ └── stylesheets │ │ │ ├── style.css │ │ │ ├── swagger-ui.css │ │ │ └── swagger-ui.css.map │ ├── queries │ │ ├── ballot.js │ │ └── user.js │ ├── routes │ │ ├── ballots.js │ │ ├── identity.js │ │ ├── index.js │ │ ├── login.js │ │ ├── logout.js │ │ ├── optout.js │ │ ├── search.js │ │ ├── tweet.js │ │ ├── users.js │ │ └── vote.js │ ├── scripts │ │ ├── datafeed.js │ │ ├── describe-table.js │ │ ├── drop-table.js │ │ ├── stats.js │ │ └── truncate-table.js │ ├── seeders │ │ ├── 20210503005614-user.js │ │ └── 20210503044348-ballot.js │ ├── tests │ │ ├── ballot.test.js │ │ ├── crypto.test.js │ │ ├── twitter.test.js │ │ └── user.test.js │ ├── utils │ │ ├── crypto.js │ │ └── twitter.js │ └── yarn.lock └── web │ ├── .eslintrc │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── components │ ├── badge.js │ ├── blue-line.js │ ├── first-badge.js │ ├── game-footer.js │ ├── game-menu.js │ ├── layout.js │ ├── leaderboard-header.js │ ├── leaderboard.js │ ├── letterhead.js │ ├── pagination.js │ ├── quadratic-given.js │ ├── quadratic-received.js │ ├── search.js │ ├── second-badge.js │ ├── tabs.js │ ├── third-badge.js │ ├── user-card.js │ ├── votes-given.js │ ├── votes-received.js │ └── white-line.js │ ├── lib │ ├── CastContext.js │ ├── LoggedContext.js │ ├── TwitterContext.js │ ├── UserContext.js │ ├── usePagination.js │ └── utils.js │ ├── next-seo.config │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ ├── 404.js │ ├── _app.js │ ├── _document.js │ ├── about.js │ ├── handles │ │ └── [username].js │ └── index.js │ ├── postcss.config.js │ ├── public │ ├── favicons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-384x384.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── hero.svg │ ├── images │ │ └── banner.png │ ├── participation.svg │ ├── people.svg │ ├── quadratic.svg │ ├── robots.txt │ ├── trust.svg │ └── trusty404.svg │ ├── styles │ ├── Home.module.css │ └── globals.css │ └── tailwind.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .editorconfig -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quadratictrust 2 | QF where you fund with your clout - not your $$$ 3 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "command": { 4 | "bootstrap": { 5 | "npmClientArgs": ["--no-package-lock"] 6 | } 7 | }, 8 | "packages": [ 9 | "packages/*" 10 | ], 11 | "version": "1.0.0" 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quadratictrust", 3 | "version": "1.0.0", 4 | "description": "Quadratic voting where you stake with your clout - not your $$$", 5 | "repository": "https://github.com/gitcoinco/quadratictrust.git", 6 | "author": "Yuet Loo Wong ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "lerna": "^4.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/api/.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.* 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 | .vscode 107 | -------------------------------------------------------------------------------- /packages/api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/api/.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('config', 'config.js') 5 | }; 6 | -------------------------------------------------------------------------------- /packages/api/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yuet Wong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/api/README.md: -------------------------------------------------------------------------------- 1 | # Quadratic Trust Backend API 2 | 3 | live demo: 4 | https://quadratictrust.com/api 5 | 6 | # Dependency 7 | * Node 8 | * Postgres 9 | * Twitter developer account 10 | 11 | # API 12 | 13 | 1. /api/users 14 | - list of top 10 users for leaderboard 15 | 2. /api/users/:username 16 | - user profile - vistor page 17 | 3. /api/search/:str 18 | - for autocomplete search 19 | 4. POST /api/login 20 | - login to twitter account 21 | 5. /api/logout 22 | - logout from twitter account 23 | 6. GET /api/ballots?voter=:username 24 | - details of votes received and gave out 25 | 7. POST /api/optout 26 | - optout of the game 27 | 8. POST /api/tweet 28 | - tweet a message 29 | - body parameters: message 30 | 9. POST /api/vote 31 | - cast a vote 32 | - body parameters: candidate, score 33 | 34 | # Setup 35 | 36 | 1. twitter 37 | 38 | 1. create a developer account at https://developer.twitter.com 39 | 2. setup keys and tokens under `Projects & Apps`: 40 | https://developer.twitter.com/en/portal/dashboard 41 | 3. setup oauth callback url in settings 42 | - Enable 3-legged OAuth 43 | 44 | 2. postgres 45 | 46 | 1. how to provision postgres on heroku: 47 | 48 | - https://devcenter.heroku.com/articles/heroku-postgresql#provisioning-heroku-postgres 49 | 50 | ``` 51 | heroku addons:create heroku-postgresql:hobby-dev 52 | ``` 53 | 54 | - to get postgres url from heroku 55 | 56 | ``` 57 | heroku pg:credentials:url DATABASE --app 58 | ``` 59 | 60 | 2. how to install postgres on ubuntu manually: 61 | Ubuntu includes PostgreSQL by default, so, can simply use the `apt-get` command: 62 | 63 | ``` 64 | apt-get install postgresql-12 65 | # createdb 66 | # createuser [username] --interactive 67 | ALTER ROLE username WITH PASSWORD 'xxx'; 68 | ``` 69 | 70 | 3. heroku 71 | 72 | If you use heroku as your hosting service, follow the following instructions: 73 | 74 | - follow the heroku instruction to download heroku cli 75 | https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up 76 | 77 | ``` 78 | heroku create 79 | git push heroku main 80 | ``` 81 | 82 | - setup config 83 | - go to https://dashboard.heroku.com app settings 84 | - click the `Reveal Config Vars` to setup these environment variables 85 | - DATABASE_URL - postgres database url 86 | - TWITTER_CONSUMER_KEY - consumer keys 87 | - TWITTER_CONSUMER_SECRET - consumer secret 88 | - TWITTER_BEARER_TOKEN - Bearer Token 89 | - TWITTER_ACCESS_TOKEN_KEY - access token key 90 | - TWITTER_ACCESS_TOKEN_SECRET - access token secret 91 | - TWITTER_CALLBACK_URL - twitter callback url settings 92 | 93 | 4. AWS hosting 94 | 95 | To use AWS ubuntu, you can setup using nginx 96 | - login to the server and open a terminal 97 | - create an environment file `.env.quadratic` in the home directory to 98 | store the environment variable settings listed under the `heroku` section 99 | - git clone this repository 100 | - follow the instruction in references to setup tls and nginx 101 | - nginx config: /etc/nginx/sites-available/default 102 | - after updating the source code, 103 | ``` 104 | cd api 105 | npm run restart 106 | sudo nginx -t 107 | sudo systemctl restart nginx 108 | ``` 109 | 110 | # Troubleshooting 111 | 112 | 1. Twitter callback url not setup 113 | - error: Desktop applications only support the oauth_callback value 'oob'/oauth/request_token 114 | 2. Postgres - gen_random_uuid() not found 115 | ``` 116 | sudo - u postgres psql 117 | \c databasename 118 | create extension pgcrypto; 119 | ``` 120 | 121 | # References 122 | - [let's encrypt and nginx](https://www.nginx.com/blog/using-free-ssltls-certificates-from-lets-encrypt-with-nginx/) 123 | - How to install certbot on ubuntu 124 | ``` 125 | which snap 126 | sudo snap install core; sudo snap refresh core 127 | sudo snap install --classic certbot 128 | sudo ln -s /snap/bin/certbot /usr/bin/certbot 129 | sudo certbot --nginx 130 | systemctl list-timers 131 | ``` 132 | -------------------------------------------------------------------------------- /packages/api/app.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors') 2 | const express = require('express') 3 | const path = require('path') 4 | const cookieParser = require('cookie-parser') 5 | const logger = require('morgan') 6 | // do not log query string 7 | logger.token('url', (req, res) => req._parsedUrl.pathname) 8 | 9 | const ONE_HOUR = 60 * 60 * 1000 10 | 11 | const { verify: verifySession } = require('./middleware/session') 12 | const db = require('./db') 13 | const RandomString = require('randomstring') 14 | const session = require('express-session') 15 | const SequelizeStore = require('connect-session-sequelize')(session.Store) 16 | const sessionStore = new SequelizeStore({ 17 | db, 18 | expiration: ONE_HOUR, // session expires every hour 19 | }) 20 | sessionStore.sync() 21 | 22 | const indexRouter = require('./routes/index') 23 | const usersRouter = require('./routes/users') 24 | const searchRouter = require('./routes/search') 25 | const loginRouter = require('./routes/login') 26 | const logoutRouter = require('./routes/logout') 27 | const tweetRouter = require('./routes/tweet') 28 | const ballotsRouter = require('./routes/ballots') 29 | const optoutRouter = require('./routes/optout') 30 | //const voteRouter = require('./routes/vote') 31 | const identityRouter = require('./routes/identity') 32 | 33 | const app = express() 34 | 35 | app.use(logger('dev')) 36 | app.use(express.json()) 37 | app.use(express.urlencoded({ extended: false })) 38 | app.use(cookieParser()) 39 | app.use( 40 | session({ 41 | secret: RandomString.generate(), 42 | store: sessionStore, 43 | resave: false, 44 | saveUninitialized: false, 45 | maxAge: ONE_HOUR, 46 | }) 47 | ) 48 | app.use(verifySession) 49 | 50 | //app.use('/api', express.static(path.join(__dirname, 'public'))) 51 | app.use('/api/users', usersRouter) 52 | app.use('/api/search', searchRouter) 53 | app.use('/api/login', loginRouter) 54 | app.use('/api/logout', logoutRouter) 55 | app.use('/api/tweet', tweetRouter) 56 | app.use('/api/ballots', ballotsRouter) 57 | //app.use('/api/vote', voteRouter) 58 | app.use('/api/optout', optoutRouter) 59 | app.use('/api/identity', identityRouter) 60 | 61 | // catch 404 and forward to error handler 62 | app.use(function (req, res, next) { 63 | next(createError(404)) 64 | }) 65 | 66 | // error handler 67 | app.use(function (err, req, res, next) { 68 | const status = err.status || 500 69 | res.status(status) 70 | res.json({ error: { status, message: err.message } }) 71 | }) 72 | 73 | module.exports = app 74 | -------------------------------------------------------------------------------- /packages/api/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('quadratic:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3003'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | console.log('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /packages/api/config/config.js: -------------------------------------------------------------------------------- 1 | require('./load-config')() 2 | console.log('database', process.env.DATABASE_URL) 3 | module.exports = { 4 | development: { 5 | use_env_variable: 'DATABASE_URL', 6 | dialect: 'postgres', 7 | ssl: true, 8 | dialectOptions: { 9 | ssl: { rejectUnauthorized: false }, 10 | }, 11 | migrationStorageTableSchema: 'public', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/config/load-config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const os = require('os') 4 | 5 | module.exports = () => { 6 | const envPath = path.join(os.homedir(), '.env.quadratic') 7 | 8 | if (fs.existsSync(envPath)) { 9 | require('dotenv').config({ path: envPath }) 10 | } else { 11 | require('dotenv').config() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/db.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('./config/load-config')() 4 | 5 | const Sequelize = require('sequelize') 6 | 7 | module.exports = new Sequelize(process.env.DATABASE_URL, { 8 | dialectOptions: { 9 | ssl: { rejectUnauthorized: false }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /packages/api/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | module.exports = { 7 | // The test environment that will be used for testing 8 | testEnvironment: "node", 9 | 10 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 11 | // testPathIgnorePatterns: [ 12 | // "/node_modules/" 13 | // ], 14 | 15 | // The regexp pattern or array of patterns that Jest uses to detect test files 16 | //testRegex: [], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/api/middleware/session.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const crypto = require('../utils/crypto') 3 | const createError = require('http-errors') 4 | const User = require('../queries/user') 5 | 6 | module.exports = { 7 | requireLogin( req, res, next ) { 8 | if( !req.auth ) { 9 | throw new createError(401, 'Require login') 10 | } 11 | next(); 12 | }, 13 | async verify( req, res, next ) { 14 | if (req.session && req.session.auth) { 15 | const { counter, data } = req.session.auth 16 | const token = crypto.decrypt(counter, data) 17 | 18 | const isOptout = await User.isOptout(token.username) 19 | if( isOptout ) { 20 | throw new createError(404, 'User opted out of this site') 21 | } 22 | req.auth = token 23 | } 24 | next(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/api/migrations/20210418232436-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tableName = 'Users' 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.sequelize 7 | .query(`CREATE TABLE IF NOT EXISTS "${tableName}" ( 8 | username VARCHAR PRIMARY KEY NOT NULL, 9 | score INTEGER, 10 | "creditsUsed" INTEGER, 11 | optout BOOL NOT NULL DEFAULT FALSE, 12 | "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP 14 | )`) 15 | 16 | await queryInterface.addIndex(tableName, ['optout'], { 17 | name: 'user_optout_ix', 18 | }) 19 | await queryInterface.addIndex(tableName, ['username'], { 20 | name: 'user_username_ix', 21 | }) 22 | await queryInterface.addIndex(tableName, ['score'], { 23 | name: 'user_score_ix', 24 | }) 25 | }, 26 | down: async (queryInterface, Sequelize) => { 27 | await queryInterface.removeIndex(tableName, 'user_optout_ix') 28 | await queryInterface.removeIndex(tableName, 'user_username_ix') 29 | await queryInterface.removeIndex(tableName, 'user_score_id') 30 | await queryInterface.dropTable(tableName) 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /packages/api/migrations/20210418232915-create-ballot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tableName = 'Ballots' 4 | 5 | module.exports = { 6 | up: async (queryInterface, Sequelize) => { 7 | await queryInterface.sequelize.query(` 8 | CREATE TABLE IF NOT EXISTS "${tableName}" ( 9 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 10 | voter VARCHAR NOT NULL, 11 | candidate VARCHAR NOT NULL, 12 | score INT NOT NULL DEFAULT 0, 13 | "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | "deletedAt" TIMESTAMPTZ, 16 | CONSTRAINT fk_ballot_candidate 17 | FOREIGN KEY(candidate) 18 | REFERENCES "Users"(username) 19 | ) 20 | `) 21 | 22 | await queryInterface.addIndex(tableName, ['voter'], { 23 | name: 'ballot_voter_ix', 24 | }) 25 | await queryInterface.addIndex(tableName, ['candidate'], { 26 | name: 'ballot_candidate_ix', 27 | }) 28 | }, 29 | down: async (queryInterface, Sequelize) => { 30 | await queryInterface.removeIndex(tableName, 'ballot_voter_ix') 31 | await queryInterface.removeIndex(tableName, 'ballot_candidate_ix') 32 | await queryInterface.dropTable(tableName) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /packages/api/migrations/20210524193721-uniq-ballots.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tableName = 'Ballots' 4 | 5 | module.exports = { 6 | up: async (queryInterface, Sequelize) => { 7 | await queryInterface.sequelize.query(` 8 | ALTER TABLE "${tableName}" 9 | ADD CONSTRAINT unique_ballot UNIQUE (voter, candidate) 10 | `) 11 | }, 12 | 13 | down: async (queryInterface, Sequelize) => { 14 | await queryInterface.sequelize.query(` 15 | ALTER TABLE "${tableName}" ( 16 | DROP CONSTRAINT unique_ballot 17 | `) 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /packages/api/models/ballot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Model } = require('sequelize') 3 | 4 | module.exports = (sequelize, DataTypes) => { 5 | class Ballot extends Model { 6 | /** 7 | * Helper method for defining associations. 8 | * This method is not a part of Sequelize lifecycle. 9 | * The `models/index` file will call this method automatically. 10 | */ 11 | static associate(models) { 12 | // define association here 13 | models.Ballot.belongsTo(models.User) 14 | } 15 | } 16 | Ballot.init( 17 | { 18 | voter: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | }, 22 | candidate: { 23 | type: DataTypes.STRING, 24 | allowNull: false, 25 | references: { 26 | model: 'Users', 27 | key: 'username', 28 | }, 29 | }, 30 | score: { 31 | type: DataTypes.INTEGER, 32 | allowNull: false, 33 | defaultValue: 0, 34 | }, 35 | createdAt: { 36 | type: DataTypes.DATE, 37 | defaultValue: DataTypes.NOW, 38 | }, 39 | updatedAt: { 40 | type: DataTypes.DATE, 41 | defaultValue: DataTypes.NOW, 42 | }, 43 | }, 44 | { 45 | sequelize, 46 | paranoid: true, 47 | modelName: 'Ballot', 48 | } 49 | ) 50 | return Ballot 51 | } 52 | -------------------------------------------------------------------------------- /packages/api/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Sequelize = require('sequelize'); 6 | const basename = path.basename(__filename); 7 | const db = {}; 8 | 9 | const sequelize = require('../db') 10 | 11 | fs 12 | .readdirSync(__dirname) 13 | .filter(file => { 14 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); 15 | }) 16 | .forEach(file => { 17 | const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); 18 | db[model.name] = model; 19 | }); 20 | 21 | Object.keys(db).forEach(modelName => { 22 | if (db[modelName].associate) { 23 | db[modelName].associate(db); 24 | } 25 | }); 26 | 27 | db.sequelize = sequelize; 28 | db.Sequelize = Sequelize; 29 | 30 | module.exports = db; 31 | -------------------------------------------------------------------------------- /packages/api/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { Model } = require('sequelize') 3 | 4 | module.exports = (sequelize, DataTypes) => { 5 | class User extends Model { 6 | /** 7 | * Helper method for defining associations. 8 | * This method is not a part of Sequelize lifecycle. 9 | * The `models/index` file will call this method automatically. 10 | */ 11 | static associate(models) { 12 | // define association here 13 | models.User.hasMany(models.Ballot) 14 | } 15 | } 16 | 17 | User.init( 18 | { 19 | username: { 20 | type: DataTypes.STRING, 21 | primaryKey: true, 22 | allowNull: false, 23 | }, 24 | score: { 25 | type: DataTypes.INTEGER, 26 | }, 27 | creditsUsed: { 28 | type: DataTypes.INTEGER, 29 | }, 30 | optout: { 31 | type: DataTypes.BOOLEAN, 32 | defaultValue: false, 33 | allowNull: false, 34 | }, 35 | createdAt: { 36 | type: DataTypes.DATE, 37 | defaultValue: DataTypes.NOW, 38 | }, 39 | updatedAt: { 40 | type: DataTypes.DATE, 41 | defaultValue: DataTypes.NOW, 42 | onUpdate: DataTypes.NOW, 43 | }, 44 | }, 45 | { 46 | sequelize, 47 | modelName: 'User', 48 | } 49 | ) 50 | 51 | return User 52 | } 53 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quadratic", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "migrate": "sequelize-cli db:migrate", 7 | "migrate:undo": "sequelize-cli db:migrate:undo", 8 | "seed": "sequelize-cli db:seed", 9 | "seed:undo": "sequelize-cli db:seed:undo", 10 | "start": "pm2 start ./bin/www", 11 | "restart": "pm2 restart ./bin/www", 12 | "stop": "pm2 stop ./bin/www", 13 | "dev": "nodemon ./bin/www", 14 | "test": "jest" 15 | }, 16 | "dependencies": { 17 | "@ethersproject/random": "^5.1.0", 18 | "@ethersproject/sha2": "^5.1.0", 19 | "aes-js": "^3.1.2", 20 | "connect-session-sequelize": "^7.1.1", 21 | "cookie-parser": "~1.4.4", 22 | "debug": "~2.6.9", 23 | "dotenv": "^8.2.0", 24 | "express": "~4.16.1", 25 | "express-session": "^1.17.1", 26 | "http-errors": "~1.6.3", 27 | "morgan": "~1.9.1", 28 | "nodemon": "^2.0.7", 29 | "pg": "^8.5.1", 30 | "pm2": "^4.5.6", 31 | "randomstring": "^1.1.5", 32 | "sequelize": "^6.6.2", 33 | "twitter-lite": "^1.1.0" 34 | }, 35 | "devDependencies": { 36 | "jest": "^26.6.3", 37 | "sequelize-cli": "^6.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/api/public/api.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Quadratic Trust API", 5 | "version": "1.0.0", 6 | "title": "Quadratic Trust API", 7 | "contact": { 8 | "email": "contact@yuetloo.com" 9 | }, 10 | "license": { 11 | "name": "Apache 2.0", 12 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 13 | } 14 | }, 15 | "host": "quadratictrust.com", 16 | "basePath": "/api", 17 | "tags": [], 18 | "schemes": [ 19 | "https" 20 | ], 21 | "paths": { 22 | "/users": { 23 | "get": { 24 | "tags": [ 25 | "users" 26 | ], 27 | "summary": "list of top users", 28 | "produces": [ 29 | "application/json" 30 | ], 31 | "parameters": [ 32 | { 33 | "name": "offset", 34 | "in": "query", 35 | "description": "The number of users to skip before starting to collect the result set", 36 | "required": false, 37 | "type": "integer" 38 | }, 39 | { 40 | "name": "limit", 41 | "in": "query", 42 | "description": "The numbers of users to return", 43 | "required": false, 44 | "type": "integer" 45 | } 46 | ], 47 | "responses": { 48 | "200": { 49 | "description": "successful operation" 50 | } 51 | } 52 | } 53 | }, 54 | "/users/{username}": { 55 | "get": { 56 | "tags": [ 57 | "users" 58 | ], 59 | "summary": "get user by the username", 60 | "produces": [ 61 | "application/json" 62 | ], 63 | "parameters": [ 64 | { 65 | "in": "path", 66 | "name": "username", 67 | "description": "The name to be searched.", 68 | "required": true, 69 | "type": "string" 70 | } 71 | ], 72 | "responses": { 73 | "200": { 74 | "description": "successful operation" 75 | } 76 | } 77 | } 78 | }, 79 | "/search/{username}": { 80 | "get": { 81 | "tags": [ 82 | "search" 83 | ], 84 | "summary": "Search user", 85 | "description": "", 86 | "produces": [ 87 | "application/json" 88 | ], 89 | "parameters": [ 90 | { 91 | "in": "path", 92 | "name": "username", 93 | "description": "The name to be searched.", 94 | "required": true, 95 | "type": "string" 96 | }, 97 | { 98 | "in": "query", 99 | "name": "limit", 100 | "description": "The number of users to return, default to 10", 101 | "type": "integer" 102 | } 103 | ], 104 | "responses": { 105 | "200": { 106 | "description": "successful operation" 107 | } 108 | } 109 | } 110 | }, 111 | "/ballots": { 112 | "get": { 113 | "tags": [ 114 | "ballots" 115 | ], 116 | "summary": "List of ballots", 117 | "description": "", 118 | "produces": [ 119 | "application/json" 120 | ], 121 | "parameters": [ 122 | { 123 | "name": "voter", 124 | "in": "query", 125 | "description": "The voter", 126 | "required": false, 127 | "type": "string" 128 | }, 129 | { 130 | "name": "candidate", 131 | "in": "query", 132 | "description": "The candidate", 133 | "required": false, 134 | "type": "string" 135 | }, 136 | { 137 | "name": "offset", 138 | "in": "query", 139 | "description": "The number of ballots to skip before starting to collect the result set", 140 | "required": false, 141 | "type": "integer" 142 | }, 143 | { 144 | "name": "limit", 145 | "in": "query", 146 | "description": "The numbers of ballots to return", 147 | "required": false, 148 | "type": "integer" 149 | } 150 | ], 151 | "responses": { 152 | "200": { 153 | "description": "successful operation" 154 | } 155 | } 156 | } 157 | }, 158 | "/login": { 159 | "get": { 160 | "tags": [ 161 | "login" 162 | ], 163 | "summary": "Logs user into the system", 164 | "description": "", 165 | "produces": [ 166 | "application/json" 167 | ], 168 | "parameters": [], 169 | "responses": { 170 | "200": { 171 | "description": "successful operation" 172 | } 173 | } 174 | } 175 | }, 176 | "/logout": { 177 | "get": { 178 | "tags": [ 179 | "logout" 180 | ], 181 | "summary": "Log out from the app", 182 | "description": "", 183 | "produces": [ 184 | "application/json" 185 | ], 186 | "parameters": [], 187 | "responses": { 188 | "200": { 189 | "description": "successful operation" 190 | } 191 | } 192 | } 193 | }, 194 | "/vote": { 195 | "post": { 196 | "tags": [ 197 | "vote" 198 | ], 199 | "summary": "Cast a vote", 200 | "description": "", 201 | "produces": [ 202 | "application/json" 203 | ], 204 | "consumes": [ 205 | "application/x-www-form-urlencoded", 206 | "application/json" 207 | ], 208 | "parameters": [ 209 | { 210 | "in": "formData", 211 | "name": "candidate", 212 | "description": "The candidate to vote for", 213 | "required": true, 214 | "type": "string" 215 | }, 216 | { 217 | "in": "formData", 218 | "name": "score", 219 | "description": "The number of vote credit to give to the candidate", 220 | "required": true, 221 | "type": "integer" 222 | } 223 | ], 224 | "security": [ 225 | { 226 | "app_auth": [ 227 | "write" 228 | ] 229 | } 230 | ], 231 | "responses": { 232 | "default": { 233 | "description": "successful operation" 234 | } 235 | } 236 | } 237 | }, 238 | "/tweet": { 239 | "post": { 240 | "tags": [ 241 | "tweet" 242 | ], 243 | "summary": "Make a tweet", 244 | "description": "", 245 | "produces": [ 246 | "application/json" 247 | ], 248 | "consumes": [ 249 | "application/x-www-form-urlencoded", 250 | "application/json" 251 | ], 252 | "parameters": [ 253 | { 254 | "name": "message", 255 | "in": "formData", 256 | "description": "The message to tweet", 257 | "required": true, 258 | "type": "string" 259 | } 260 | ], 261 | "security": [ 262 | { 263 | "app_auth": [ 264 | "write" 265 | ] 266 | } 267 | ], 268 | "responses": { 269 | "200": { 270 | "description": "successful operation", 271 | "schema": { 272 | "$ref": "#/definitions/User" 273 | } 274 | }, 275 | "401": { 276 | "description": "Require login" 277 | } 278 | } 279 | } 280 | }, 281 | "/optout": { 282 | "post": { 283 | "tags": [ 284 | "optout" 285 | ], 286 | "summary": "Opt out of using this app", 287 | "description": "", 288 | "produces": [ 289 | "application/json" 290 | ], 291 | "parameters": [], 292 | "responses": { 293 | "401": { 294 | "description": "Require login" 295 | } 296 | }, 297 | "security": [ 298 | { 299 | "app_auth": [ 300 | "write" 301 | ] 302 | } 303 | ] 304 | } 305 | }, 306 | "/identity": { 307 | "get": { 308 | "tags": [ 309 | "identity" 310 | ], 311 | "summary": "Get information about the logged in user", 312 | "description": "", 313 | "produces": [ 314 | "application/json" 315 | ], 316 | "parameters": [], 317 | "responses": { 318 | "200": { 319 | "description": "successful operation" 320 | } 321 | }, 322 | "security": [ 323 | { 324 | "app_auth": [ 325 | "read" 326 | ] 327 | } 328 | ] 329 | 330 | } 331 | } 332 | }, 333 | "securityDefinitions": { 334 | "app_auth": { 335 | "type": "oauth2", 336 | "authorizationUrl": "https://quadratictrust.com/api/login", 337 | "flow": "authorizationCode" 338 | } 339 | }, 340 | "definitions": { 341 | "User": { 342 | "type": "object", 343 | "properties": { 344 | "username": { 345 | "type": "string" 346 | }, 347 | "name": { 348 | "type": "string" 349 | }, 350 | "profileUrl": { 351 | "type": "string" 352 | }, 353 | "rank": { 354 | "type": "integer" 355 | }, 356 | "score": { 357 | "type": "integer" 358 | }, 359 | "credits": { 360 | "type": "integer" 361 | } 362 | } 363 | } 364 | } 365 | } -------------------------------------------------------------------------------- /packages/api/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Quadratic Trust API 7 | 8 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /packages/api/public/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger UI: OAuth2 Redirect 5 | 6 | 7 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /packages/api/public/question-circle-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/api/public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | 10 | .center { 11 | text-align: center; 12 | } 13 | 14 | #login-button:hover { 15 | cursor: pointer; 16 | } -------------------------------------------------------------------------------- /packages/api/queries/ballot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Twitter = require('../utils/twitter') 3 | const { DataTypes, QueryTypes, fn, col } = require('sequelize') 4 | const db = require('../db') 5 | 6 | require('../models/ballot')(db, DataTypes) 7 | const { Ballot } = db.models 8 | 9 | const attributes = ['voter', 'candidate', 'score', 'createdAt'] 10 | 11 | const buildFilter = async ({ voter, candidate }) => { 12 | const filter = {} 13 | if (voter) filter.voter = voter 14 | if (candidate) filter.candidate = candidate 15 | return filter 16 | } 17 | 18 | module.exports = { 19 | count: async (args) => { 20 | const filter = await buildFilter(args) 21 | 22 | const count = await Ballot.count({ 23 | where: filter, 24 | }) 25 | 26 | return count 27 | }, 28 | get: async ({ voter, candidate, limit, offset = 0 } = {}) => { 29 | const filter = await buildFilter({ voter, candidate }) 30 | 31 | const result = await Ballot.findAll({ 32 | attributes, 33 | where: filter, 34 | order: [['createdAt', 'DESC']], 35 | offset, 36 | limit, 37 | }) 38 | 39 | const twitter = new Twitter() 40 | const ballots = result.map((v) => v.dataValues) 41 | const voters = ballots.map((b) => ({ username: b.voter })) 42 | const voterUrls = await twitter.userWithProfileUrl(voters) 43 | const candidates = ballots.map((b) => ({ username: b.candidate })) 44 | const candidateUrls = await twitter.userWithProfileUrl(candidates) 45 | 46 | return ballots.map((ballot, i) => { 47 | const { profileUrl: voterProfileUrl } = voterUrls[i] 48 | const { profileUrl: candidateProfileUrl } = candidateUrls[i] 49 | return { ...ballot, voterProfileUrl, candidateProfileUrl } 50 | }) 51 | }, 52 | delete: (options) => { 53 | return Ballot.destroy(options) 54 | }, 55 | save: ({ voter, candidate, score, transaction }) => { 56 | return Ballot.create({ voter, candidate, score }, { transaction }) 57 | }, 58 | getCandidates: async (voter, transaction) => { 59 | const ballots = await Ballot.findAll({ 60 | attributes: [[fn('DISTINCT', col('candidate')), 'candidate']], 61 | where: { voter }, 62 | transaction, 63 | raw: true, 64 | }) 65 | 66 | return ballots ? ballots.map((b) => b.candidate) : [] 67 | }, 68 | sumScore: async (candidate, transaction) => { 69 | const score = await Ballot.sum('score', { 70 | where: { candidate }, 71 | transaction, 72 | }) 73 | return score 74 | }, 75 | sumCreditsUsed: async (voter, transaction) => { 76 | const result = await db.query( 77 | `SELECT SUM(score * score) "creditsUsed" FROM "Ballots" WHERE voter = $voter`, 78 | { 79 | bind: { voter }, 80 | type: QueryTypes.SELECT, 81 | transaction, 82 | plain: true, 83 | } 84 | ) 85 | return Number(result?.creditsUsed || 0) 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /packages/api/queries/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Twitter = require('../utils/twitter') 4 | const { QueryTypes, DataTypes, Op } = require('sequelize') 5 | const db = require('../db') 6 | 7 | require('../models/user')(db, DataTypes) 8 | const { User } = db.models 9 | const Ballot = require('./ballot') 10 | 11 | const rankSubquery = ` 12 | SELECT 13 | username, 14 | score, 15 | "creditsUsed", 16 | "createdAt", 17 | DENSE_RANK () OVER (ORDER BY score DESC, "createdAt" ASC) as rank 18 | FROM "Users" 19 | WHERE optout = false AND score IS NOT NULL` 20 | 21 | const topUserQuery = ({ limit = 10, offset = 0 }) => { 22 | return ` 23 | WITH cte AS (${rankSubquery}) 24 | SELECT * FROM cte 25 | ORDER BY score DESC, "createdAt" ASC 26 | LIMIT ${limit} OFFSET ${offset} 27 | ` 28 | } 29 | 30 | const userQuery = ` 31 | WITH cte AS (${rankSubquery}) 32 | SELECT u.username, u.optout, u.score, u."creditsUsed", rank 33 | FROM "Users" u 34 | LEFT OUTER JOIN cte ON u.username = cte.username 35 | where u.username = $username 36 | ` 37 | 38 | const searchQuery = ({ limit = 10, offset = 0 }) => ` 39 | WITH cte AS (${rankSubquery}) 40 | SELECT u.username, u.optout, u.score, u."creditsUsed", rank 41 | FROM "Users" u 42 | LEFT OUTER JOIN cte ON u.username = cte.username 43 | WHERE u.username ILIKE $username 44 | AND u.optout = false 45 | LIMIT ${limit} OFFSET ${offset} 46 | ` 47 | 48 | const updateUserScore = async (username, transaction) => { 49 | const score = await Ballot.sumScore(username, transaction) 50 | await User.update( 51 | { score }, 52 | { 53 | where: { username }, 54 | transaction, 55 | } 56 | ) 57 | return score 58 | } 59 | 60 | const updateCreditsUsed = async (username, transaction) => { 61 | const creditsUsed = await Ballot.sumCreditsUsed(username, transaction) 62 | await User.update( 63 | { creditsUsed }, 64 | { 65 | where: { username }, 66 | transaction, 67 | } 68 | ) 69 | return creditsUsed 70 | } 71 | 72 | const adjustCandidateScore = async ({ voter, transaction }) => { 73 | const candidates = await Ballot.getCandidates(voter, transaction) 74 | for (let candidate of candidates) { 75 | await updateUserScore(candidate, transaction) 76 | } 77 | } 78 | 79 | module.exports = { 80 | isOptout: async (username = '') => { 81 | const result = await User.findOne({ 82 | attributes: ['optout'], 83 | where: { 84 | optout: true, 85 | username, 86 | }, 87 | raw: true, 88 | }) 89 | 90 | return Boolean(result) 91 | }, 92 | getUser: async (username = '') => { 93 | const users = await db.query(userQuery, { 94 | bind: { username }, 95 | type: QueryTypes.SELECT, 96 | }) 97 | 98 | if (users.length === 0) { 99 | users.push({ username }) 100 | } else if (users[0].optout) { 101 | return null 102 | } 103 | 104 | const twitter = new Twitter() 105 | const profiles = await twitter.getUserProfiles(users) 106 | return profiles[0] 107 | }, 108 | getTopUsers: async ({ offset = 0, limit = 10 } = {}) => { 109 | const users = await db.query(topUserQuery({ offset, limit }), { 110 | type: QueryTypes.SELECT, 111 | }) 112 | 113 | const twitter = new Twitter() 114 | return twitter.getUserProfiles(users) 115 | }, 116 | count: async () => { 117 | const data = await User.count({ 118 | where: { 119 | optout: false, 120 | score: { 121 | [Op.ne]: null, 122 | }, 123 | }, 124 | raw: true, 125 | }) 126 | 127 | return data 128 | }, 129 | searchUsers: async (userSearch, limit = 10) => { 130 | const users = await db.query(searchQuery({ limit }), { 131 | type: QueryTypes.SELECT, 132 | bind: { username: `%${userSearch}%` }, 133 | }) 134 | 135 | const twitter = new Twitter() 136 | return twitter.getUserProfiles(users) 137 | }, 138 | filterOptout: async (users) => { 139 | if (!users || users.length === 0) return [] 140 | 141 | const usernames = users.map((u) => u.username) 142 | const result = await User.findAll({ 143 | attributes: ['username'], 144 | where: { 145 | optout: true, 146 | username: usernames, 147 | }, 148 | raw: true, 149 | }) 150 | 151 | if (result.length === 0) { 152 | return users 153 | } 154 | 155 | const optoutUsers = new Set(result) 156 | return users.filter((u) => !optoutUsers.has(u.username)) 157 | }, 158 | addVoteInfo: async (users) => { 159 | if (!users || users.length === 0) return [] 160 | 161 | const result = await Promise.all( 162 | users.map((user) => 163 | db 164 | .query(userQuery, { 165 | bind: { username: user.username }, 166 | type: QueryTypes.SELECT, 167 | }) 168 | .then(([dbUser]) => { 169 | return dbUser ? { ...user, ...dbUser } : user 170 | }) 171 | ) 172 | ) 173 | 174 | return result 175 | }, 176 | castVote: async ({ voter, candidate, score }) => { 177 | const transaction = await db.queryInterface.sequelize.transaction() 178 | try { 179 | await User.findOrCreate({ 180 | where: { username: candidate }, 181 | transaction, 182 | }) 183 | await User.findOrCreate({ 184 | where: { username: voter }, 185 | transaction, 186 | }) 187 | await Ballot.save({ voter, candidate, score, transaction }) 188 | const newScore = await updateUserScore(candidate, transaction) 189 | await updateCreditsUsed(voter, transaction) 190 | await transaction.commit() 191 | return newScore 192 | } catch (err) { 193 | console.log('castVote error', err) 194 | await transaction.rollback() 195 | throw err 196 | } 197 | }, 198 | setOptout: async (username) => { 199 | if (!username) return 200 | 201 | const transaction = await db.queryInterface.sequelize.transaction() 202 | const now = new Date() 203 | const voter = username 204 | 205 | try { 206 | const res = await User.update( 207 | { optout: true, updatedAt: now }, 208 | { 209 | where: { 210 | username, 211 | }, 212 | transaction, 213 | } 214 | ) 215 | 216 | await adjustCandidateScore({ voter, transaction }) 217 | 218 | await Ballot.delete({ 219 | where: { 220 | [Op.or]: { 221 | voter, 222 | candidate: username, 223 | }, 224 | }, 225 | transaction, 226 | }) 227 | 228 | await transaction.commit() 229 | } catch (err) { 230 | console.log('err', err) 231 | await transaction.rollback() 232 | throw err 233 | } 234 | }, 235 | } 236 | -------------------------------------------------------------------------------- /packages/api/routes/ballots.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const Ballot = require('../queries/ballot') 4 | 5 | /* 6 | * GET /ballots 7 | * Retrive ballots 8 | * Query: 9 | * voter: votes given by this user 10 | * candidate: votes received by this user 11 | * offset: rows starting offset 12 | * limit: max number of rows to return 13 | */ 14 | router.get('/', async (req, res, next) => { 15 | const { voter, candidate, limit, offset = 0 } = req.query 16 | 17 | try { 18 | const filter = { candidate, voter } 19 | const ballots = await Ballot.get({ ...filter, limit, offset }) 20 | const data = { ballots } 21 | if (offset === 0) { 22 | data.total = await Ballot.count(filter) 23 | } 24 | res.json({ data }) 25 | } catch (e) { 26 | next(e) 27 | } 28 | }) 29 | 30 | module.exports = router 31 | -------------------------------------------------------------------------------- /packages/api/routes/identity.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const User = require('../queries/user') 4 | 5 | /* identity */ 6 | router.get('/', async (req, res, next) => { 7 | try { 8 | let user = { username: null } 9 | if (req.auth) { 10 | user = await User.getUser(req.auth.username) 11 | } 12 | res.json(user) 13 | } catch (err) { 14 | console.log('errr', err) 15 | next(err) 16 | } 17 | }) 18 | 19 | module.exports = router 20 | -------------------------------------------------------------------------------- /packages/api/routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get('/', function(req, res, next) { 6 | res.render('index'); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /packages/api/routes/login.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const Twitter = require('../utils/twitter') 4 | const crypto = require('../utils/crypto') 5 | const User = require('../queries/user') 6 | 7 | /* login */ 8 | router.get('/', async (req, res, next) => { 9 | try { 10 | const twitter = new Twitter() 11 | const { oauth_token } = await twitter.getRequestToken() 12 | res.redirect( 13 | `https://api.twitter.com/oauth/authenticate?oauth_token=${oauth_token}` 14 | ) 15 | } catch (err) { 16 | console.log('errr', err) 17 | next(err) 18 | } 19 | }) 20 | 21 | router.get('/twitter-callback', async (req, res, next) => { 22 | const { oauth_verifier: oauthVerifier, oauth_token: oauthToken } = req.query 23 | 24 | try { 25 | if (!oauthVerifier || !oauthToken) throw new Error('Missing oauth token') 26 | 27 | const twitter = new Twitter() 28 | const token = await twitter.getAccessToken({ 29 | oauthVerifier, 30 | oauthToken, 31 | }) 32 | 33 | const username = token.screen_name 34 | 35 | const auth = crypto.encrypt({ 36 | username, 37 | tokenKey: token.oauth_token, 38 | tokenSecret: token.oauth_token_secret, 39 | }) 40 | 41 | const isOptout = await User.isOptout(username) 42 | if (isOptout) { 43 | throw new Error('Not authorized') 44 | } 45 | 46 | req.session.auth = auth 47 | res.redirect('/') 48 | } catch (err) { 49 | console.log('callback error', err) 50 | next({ status: 401, message: err.message }) 51 | } 52 | }) 53 | 54 | module.exports = router 55 | -------------------------------------------------------------------------------- /packages/api/routes/logout.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const express = require('express'); 4 | const router = express.Router(); 5 | 6 | /* logout */ 7 | router.get('/', async (req, res, next) => { 8 | req.session = null 9 | res.clearCookie('connect.sid') 10 | res.json({ success: true }) 11 | }) 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /packages/api/routes/optout.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { requireLogin } = require('../middleware/session'); 3 | const router = express.Router(); 4 | const User = require('../queries/user') 5 | 6 | // POST /optout 7 | router.post('/', requireLogin, async (req, res, next) => { 8 | 9 | const { username } = req.auth 10 | 11 | try { 12 | if( !username ) { 13 | throw new Error('Invalid username') 14 | } 15 | 16 | const result = await User.setOptout(username) 17 | const data = { success: true } 18 | res.json({ data }); 19 | } catch (e) { 20 | next(e) 21 | } 22 | }); 23 | 24 | module.exports = router; -------------------------------------------------------------------------------- /packages/api/routes/search.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const Twitter = require('../utils/twitter') 4 | const User = require('../queries/user') 5 | 6 | router.get('/:username', async (req, res, next) => { 7 | const { username = '' } = req.params 8 | const { limit = 10 } = req.query 9 | 10 | try { 11 | const twitter = new Twitter() 12 | const users = await User.searchUsers(username, limit) 13 | 14 | let page = 0 15 | while (users.length < limit) { 16 | const twitterUsers = await twitter.searchUsers(username, { 17 | count: limit - users.length, 18 | page: page++, 19 | }) 20 | 21 | if (!twitterUsers || twitterUsers.length === 0) break 22 | 23 | const userLookup = new Set(users.map((u) => u.username)) 24 | const uniqueUsers = twitterUsers.filter( 25 | (u) => !userLookup.has(u.username) 26 | ) 27 | 28 | // no more unique users 29 | if (uniqueUsers.length === 0) break 30 | 31 | const filtered = await User.filterOptout(uniqueUsers) 32 | 33 | const userWithVoteInfo = await User.addVoteInfo(filtered) 34 | users.push(...userWithVoteInfo) 35 | } 36 | 37 | res.send({ users }) 38 | } catch (e) { 39 | next(e) 40 | } 41 | }) 42 | 43 | module.exports = router 44 | -------------------------------------------------------------------------------- /packages/api/routes/tweet.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const express = require('express'); 4 | const router = express.Router(); 5 | const Twitter = require('../utils/twitter') 6 | const { requireLogin } = require('../middleware/session') 7 | 8 | // tweet 9 | router.post('/', requireLogin, async (req, res, next) => { 10 | const { 11 | tokenKey: accessTokenKey, 12 | tokenSecret: accessTokenSecret 13 | } = req.auth 14 | 15 | const status = req.body.message 16 | 17 | try { 18 | const twitter = new Twitter({ accessTokenKey, accessTokenSecret }) 19 | await twitter.postTweet(status) 20 | res.send({ success: true }) 21 | } catch (e) { 22 | console.log('tweet error', e) 23 | next(e) 24 | } 25 | }) 26 | 27 | module.exports = router; 28 | -------------------------------------------------------------------------------- /packages/api/routes/users.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router() 3 | const Twitter = require('../utils/twitter') 4 | const User = require('../queries/user') 5 | 6 | router.get('/', async (req, res, next) => { 7 | const { offset = 0, limit } = req.query 8 | 9 | try { 10 | const users = await User.getTopUsers({ offset, limit }) 11 | 12 | // provide a count for the first batch for pagination 13 | const total = offset == 0 ? await User.count() : undefined 14 | 15 | res.send({ users, total }) 16 | } catch (e) { 17 | next(e) 18 | } 19 | }) 20 | 21 | router.get('/:username', async (req, res, next) => { 22 | const { username = '' } = req.params 23 | 24 | try { 25 | const user = await User.getUser(username) 26 | res.send({ user }) 27 | } catch (e) { 28 | next(e) 29 | } 30 | }) 31 | 32 | module.exports = router 33 | -------------------------------------------------------------------------------- /packages/api/routes/vote.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const createError = require('http-errors') 3 | const { requireLogin } = require('../middleware/session') 4 | const router = express.Router() 5 | const User = require('../queries/user') 6 | const Twitter = require('../utils/twitter') 7 | 8 | function isEqual(user1 = '', user2 = '') { 9 | return user1.localeCompare(user2, 'en', { sensitivity: 'base' }) === 0 10 | } 11 | 12 | async function validate(req) { 13 | const { username: voter } = req.auth 14 | const { candidate, score } = req.body 15 | if (!candidate) { 16 | throw new createError(400, 'Missing candidate') 17 | } 18 | if (isEqual(candidate, voter)) { 19 | throw new createError(400, 'Cannot vote for yourself') 20 | } 21 | if (!score) { 22 | throw new createError(400, 'Missing score') 23 | } 24 | 25 | const twitter = new Twitter() 26 | try { 27 | const profile = await twitter.getUserProfile({ username: candidate }) 28 | if (!profile) { 29 | throw new createError(400, 'Candidate is invalid twitter user') 30 | } 31 | } catch (e) { 32 | throw new createError(400, e.message) 33 | } 34 | } 35 | 36 | /* 37 | * POST /api/vote 38 | * 39 | */ 40 | router.post('/', requireLogin, async (req, res, next) => { 41 | const { username: voter } = req.auth 42 | const { candidate, score } = req.body 43 | 44 | try { 45 | await validate(req) 46 | const requiredCredits = score * score 47 | const user = await User.getUser(voter) 48 | const availableCredits = user.credits || 0 49 | if (availableCredits < requiredCredits) { 50 | throw new Error('Not enough credits') 51 | } 52 | const newScore = await User.castVote({ voter, candidate, score }) 53 | const data = { newScore } 54 | res.send({ data }) 55 | } catch (e) { 56 | let error = e 57 | if (e.message === 'Validation error' && e.parent) { 58 | error = createError(400, `${e.message}: ${e.parent.detail}`) 59 | } 60 | console.log('POST /api/vote error', e) 61 | next(error) 62 | } 63 | }) 64 | 65 | module.exports = router 66 | -------------------------------------------------------------------------------- /packages/api/scripts/datafeed.js: -------------------------------------------------------------------------------- 1 | const db = require('../db') 2 | 3 | async function main() { 4 | const queryInterface = db.getQueryInterface() 5 | /* 6 | await queryInterface.bulkInsert('Ballots', [{ 7 | candidate: 'yuetloo', 8 | voter: 'ricmoo', 9 | score: 4 10 | }], {}) 11 | */ 12 | await queryInterface.sequelize.query(`Update "Users" set score = 4 where username = 'yuetloo'`) 13 | 14 | } 15 | 16 | main().then(() => { 17 | console.log('done ballot data') 18 | }).catch(e => { 19 | console.log('error feeding ballot data', e) 20 | }).finally(() => { 21 | db.close() 22 | }) 23 | -------------------------------------------------------------------------------- /packages/api/scripts/describe-table.js: -------------------------------------------------------------------------------- 1 | const db = require('../db') 2 | const User = require('../queries/user') 3 | const Ballot = require('../queries/ballot') 4 | const { QueryTypes } = require('sequelize') 5 | 6 | const queryInterface = db.getQueryInterface() 7 | 8 | async function main() { 9 | const user = await queryInterface.describeTable('Users'); 10 | console.log('user', user); 11 | const ballot = await queryInterface.describeTable('Ballots'); 12 | console.log('ballot', ballot); 13 | 14 | const users = await User.getTopUsers() 15 | console.log('users', users) 16 | 17 | const ballots = await Ballot.get() 18 | console.log('ballots', ballots) 19 | 20 | const rawUsers = await queryInterface.sequelize.query(`select * from "Users" where username = 'yuetloo'`, 21 | { 22 | type: QueryTypes.SELECT 23 | }) 24 | console.log('rawUsers', rawUsers) 25 | } 26 | 27 | 28 | main().then(() => console.log('done')) 29 | -------------------------------------------------------------------------------- /packages/api/scripts/drop-table.js: -------------------------------------------------------------------------------- 1 | const db = require('../db') 2 | 3 | async function main() { 4 | const queryInterface = db.getQueryInterface() 5 | await queryInterface.dropTable('Ballots'); 6 | await queryInterface.dropTable('Users'); 7 | await queryInterface.dropTable('ballots'); 8 | await queryInterface.dropTable('users'); 9 | await queryInterface.dropTable('SequelizeMeta'); 10 | } 11 | 12 | main().then(() => { 13 | console.log('done dropping tables') 14 | }).catch(e => { 15 | console.log('error dropping tables', e) 16 | }).finally(() => { 17 | db.close() 18 | }) 19 | -------------------------------------------------------------------------------- /packages/api/scripts/stats.js: -------------------------------------------------------------------------------- 1 | const db = require('../db') 2 | const { QueryTypes } = require('sequelize') 3 | 4 | const queryInterface = db.getQueryInterface() 5 | 6 | const queries = [ 7 | `SELECT count(distinct voter) fROM "Ballots" 8 | WHERE "deletedAt" is null`, 9 | `SELECT count(distinct candidate) fROM "Ballots" 10 | WHERE "deletedAt" is null`, 11 | `SELECT optout, count(1) fROM "Users" 12 | GROUP BY optout`, 13 | ] 14 | 15 | async function main() { 16 | const option = { type: QueryTypes.SELECT } 17 | 18 | for (let i = 0; i < queries.length; i++) { 19 | const query = queries[i] 20 | const result = await queryInterface.sequelize.query(query, option) 21 | console.log(result); 22 | } 23 | } 24 | 25 | 26 | main() 27 | .then(() => console.log('done')) 28 | .catch(console.error) 29 | .finally(() => { 30 | db.close() 31 | }) 32 | -------------------------------------------------------------------------------- /packages/api/scripts/truncate-table.js: -------------------------------------------------------------------------------- 1 | const db = require('../db') 2 | 3 | async function main() { 4 | const queryInterface = db.getQueryInterface() 5 | await queryInterface.bulkDelete('Ballots', null, { truncate: true }); 6 | await queryInterface.bulkDelete('Users', null, { truncate: true, cascade: true }); 7 | } 8 | 9 | main().then(() => { 10 | console.log('done truncating tables') 11 | }).catch(e => { 12 | console.log('error truncating tables', e) 13 | }).finally(() => { 14 | db.close() 15 | }) 16 | -------------------------------------------------------------------------------- /packages/api/seeders/20210503005614-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableName = 'Users' 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.bulkInsert(tableName, [ 7 | { username: 'gitcoin' }, 8 | { username: 'owocki' }, 9 | { username: 'Anne_Connelly' }, 10 | { username: 'VitalikButerin' }, 11 | { username: 'elonmusk' }, 12 | { username: 'jack' }, 13 | { username: 'SprinklesNFT' }, 14 | { username: 'hi_firefly' }, 15 | { username: 'ricmoo' }, 16 | { username: 'yuetloo' }, 17 | { username: 'Loo27464703' }, 18 | ]) 19 | }, 20 | 21 | down: async (queryInterface, Sequelize) => { 22 | await queryInterface.bulkDelete(tableName, null, {}); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api/seeders/20210503044348-ballot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableName = 'Ballots' 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.bulkInsert(tableName, [ 7 | { voter: 'ricmoo', candidate: 'yuetloo', score: 4 } 8 | ]) 9 | }, 10 | 11 | down: async (queryInterface, Sequelize) => { 12 | await queryInterface.bulkDelete(tableName, null, {}); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /packages/api/tests/ballot.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const db = require('../db') 3 | const Ballot = require('../queries/ballot') 4 | 5 | afterAll(() => db.close()) 6 | 7 | test('get ballot by voter should return votes in descending order of creation time', async () => { 8 | const username = 'yuetloo' 9 | const votes = await Ballot.get({ voter: username, limit: 2 }) 10 | console.log(votes) 11 | await expect(votes.length).toBeGreaterThanOrEqual(0) 12 | }) 13 | 14 | test('get credits used for valid voter', async () => { 15 | const voter = 'yuetloo' 16 | const transaction = await db.queryInterface.sequelize.transaction() 17 | const used = await Ballot.sumCreditsUsed(voter, transaction) 18 | await transaction.commit() 19 | await expect(used).toBeGreaterThan(0) 20 | }) 21 | 22 | test('get credits used for invalid user', async () => { 23 | const voter = 'xxx' 24 | const transaction = await db.queryInterface.sequelize.transaction() 25 | const used = await Ballot.sumCreditsUsed(voter, transaction) 26 | await transaction.commit() 27 | await expect(used).toEqual(0) 28 | }) 29 | 30 | test('test getCandidates', async () => { 31 | const voter = 'yuetloo' 32 | const transaction = await db.queryInterface.sequelize.transaction() 33 | const candidates = await Ballot.getCandidates(voter, transaction) 34 | await transaction.commit() 35 | await expect(candidates.length).toBeGreaterThanOrEqual(0) 36 | }) 37 | -------------------------------------------------------------------------------- /packages/api/tests/crypto.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('../utils/crypto'); 4 | 5 | test('encrypt and descrypt session data should work', async () => { 6 | const tokenKey = 'abc' 7 | const tokenSecret = 'edf' 8 | 9 | const { counter, data } = crypto.encrypt(tokenKey, tokenSecret) 10 | const token = crypto.decrypt(counter, data) 11 | 12 | expect(token.tokenKey).toEqual(tokenKey) 13 | expect(token.tokenSecret).toEqual(tokenSecret) 14 | 15 | }); -------------------------------------------------------------------------------- /packages/api/tests/twitter.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Twitter = require('../utils/twitter') 4 | 5 | require('../config/load-config')() 6 | const accessTokenKey = 'blah' 7 | const accessTokenSecret = 'blah' 8 | 9 | test.skip('make a tweet should work', async () => { 10 | const client = new Twitter({ accessTokenKey, accessTokenSecret }) 11 | const status = 'miracle world' 12 | const tweetId = await client.postTweet(status) 13 | expect(tweetId).toBeDefined() 14 | }) 15 | 16 | test.skip('verify twitter account should work', async () => { 17 | const client = new Twitter({ accessTokenKey, accessTokenSecret }) 18 | const user = await client.verifyCredentials() 19 | expect(user).toBeDefined() 20 | }) 21 | 22 | test('search user should work', async () => { 23 | const client = new Twitter() 24 | const options = { 25 | count: 10, 26 | } 27 | for (let i = 0; i < 3; i++) { 28 | options.page = i 29 | const users = await client.searchUsers('a', options) 30 | expect(users).toBeDefined() 31 | } 32 | }) 33 | 34 | test('userWithProfileUrl should work', async () => { 35 | const client = new Twitter() 36 | const users = [{ username: 'yuetloo' }, { username: 'yuetloo_fake' }] 37 | const usersWithUrl = await client.userWithProfileUrl(users) 38 | 39 | expect(usersWithUrl).toHaveLength(users.length) 40 | expect(usersWithUrl[0]).toHaveProperty('profileUrl') 41 | expect(usersWithUrl[1]).toHaveProperty('profileUrl') 42 | expect(usersWithUrl[1].profileUrl).toEqual( 43 | expect.stringMatching(/default_profile_bigger\.png$/) 44 | ) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/api/tests/user.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const User = require('../queries/user') 4 | 5 | afterAll(() => require('../db').close()) 6 | 7 | test('setOptout should work', async () => { 8 | const username = 'yuetloo' 9 | await User.setOptout(username) 10 | const users = await User.searchUsers(username) 11 | await expect(users.length).toBe(0) 12 | }) 13 | 14 | test('castVote should work', async () => { 15 | const candidate = 'gitcoin' 16 | const score = 6 17 | await User.castVote({ voter: 'ricmoo', candidate, score }) 18 | const users = await User.searchUsers(candidate) 19 | expect(users.length).toBe(1) 20 | expect(users[0].score >= 6).toBe(true) 21 | }) 22 | 23 | test('getUser should return twitter profile if user does not exist in table', async () => { 24 | const user = await User.getUser('melindagates') 25 | expect(user).toBeDefined 26 | expect(user.credits).toBeGreaterThan(0) 27 | }) 28 | 29 | test('isOptout should work', async () => { 30 | const isOptout = await User.isOptout('yuetloo') 31 | expect(isOptout).toBe(true) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/api/utils/crypto.js: -------------------------------------------------------------------------------- 1 | const { randomBytes } = require('@ethersproject/random') 2 | const { sha256 } = require('@ethersproject/sha2') 3 | const aesjs = require('aes-js'); 4 | 5 | class Crypto { 6 | constructor() { 7 | this.key = randomBytes(16) 8 | } 9 | 10 | encrypt( token ) { 11 | const tokenString = JSON.stringify(token) 12 | const dataBytes = aesjs.utils.utf8.toBytes(tokenString); 13 | 14 | // make a 16 bytes counter from the data 15 | const hash = sha256(dataBytes).slice(2, 34) 16 | const counter = aesjs.utils.hex.toBytes(hash) 17 | 18 | const aesCtr = new aesjs.ModeOfOperation.ctr(this.key, new aesjs.Counter(counter)); 19 | const encryptedBytes = aesCtr.encrypt(dataBytes); 20 | 21 | const encryptedHex = aesjs.utils.hex.fromBytes(encryptedBytes); 22 | 23 | return { counter: hash, data: encryptedHex } 24 | } 25 | 26 | decrypt( counter, data ) { 27 | const counterBytes = aesjs.utils.hex.toBytes(counter) 28 | const aesCtr = new aesjs.ModeOfOperation.ctr(this.key, new aesjs.Counter(counterBytes)); 29 | 30 | const encryptedBytes = aesjs.utils.hex.toBytes(data) 31 | const decryptedBytes = aesCtr.decrypt(encryptedBytes); 32 | 33 | // Convert bytes back into text 34 | const decryptedText = aesjs.utils.utf8.fromBytes(decryptedBytes); 35 | const token = JSON.parse(decryptedText) 36 | return token 37 | } 38 | } 39 | 40 | module.exports = new Crypto() 41 | -------------------------------------------------------------------------------- /packages/api/utils/twitter.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors') 2 | const TwitterLite = require('twitter-lite') 3 | 4 | require('../config/load-config')() 5 | 6 | const USER_PROFILE_FIELDS = 'name,profile_image_url,public_metrics' 7 | const MAX_USER_PROFILES = 100 8 | 9 | const TWITTER_DEFAULT_PROFILE_URL = 10 | 'https://abs.twimg.com/sticky/default_profile_images/default_profile_bigger.png' 11 | 12 | const handleError = (e) => { 13 | if ('errors' in e) { 14 | // Twitter API error 15 | if (e.errors[0].code === 88) { 16 | // rate limit exceeded 17 | throw new createError( 18 | 429, 19 | 'Rate limit will reset on', 20 | new Date(e._headers.get('x-rate-limit-reset') * 1000) 21 | ) 22 | } else { 23 | throw new createError(400, e.errors[0].message) 24 | } 25 | } 26 | throw new createError(500, e.message) 27 | } 28 | 29 | // twitter profile url is typically of size 48x48, the _bigger variants is of size 73x73 30 | // https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners 31 | const toBiggerResolutionUrl = (url = '') => { 32 | return url.replace(/\_normal\.(?=[^.]*$)/, '_bigger.') 33 | } 34 | 35 | const createClient = ({ 36 | bearerToken, 37 | accessTokenKey, 38 | accessTokenSecret, 39 | } = {}) => 40 | bearerToken 41 | ? new TwitterLite({ 42 | version: '2', 43 | extension: false, 44 | bearer_token: bearerToken, 45 | }) 46 | : new TwitterLite({ 47 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 48 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 49 | access_token_key: accessTokenKey, 50 | access_token_secret: accessTokenSecret, 51 | }) 52 | 53 | const calculateCredits = (total = 0, used = 0) => { 54 | return total - used 55 | } 56 | 57 | const buildUserProfile = (users, userMap) => { 58 | const profiles = users.map((u) => { 59 | const extra = userMap.get(u.username) 60 | const rank = u.rank ? parseInt(u.rank, 10) : null 61 | if (!extra) { 62 | return { 63 | username: u.username, 64 | name: u.username, 65 | profileUrl: TWITTER_DEFAULT_PROFILE_URL, 66 | rank: rank, 67 | score: u.score, 68 | credits: 0, 69 | } 70 | } 71 | return { 72 | username: u.username, 73 | name: extra.name, 74 | profileUrl: toBiggerResolutionUrl(extra.profile_image_url), 75 | rank: rank, 76 | score: u.score || null, 77 | credits: calculateCredits( 78 | extra.public_metrics.followers_count, 79 | u.creditsUsed 80 | ), 81 | } 82 | }) 83 | return profiles 84 | } 85 | 86 | const getUserLookupMap = async (users) => { 87 | if (users.length > MAX_USER_PROFILES) { 88 | throw new Error(`Too many users. Only supports ${MAX_USER_PROFILES}`) 89 | } 90 | 91 | const client = createClient({ 92 | bearerToken: process.env.TWITTER_BEARER_TOKEN, 93 | }) 94 | const url = 'users/by' 95 | 96 | const usernames = users.map((u) => u.username).join(',') 97 | const response = await client.get(url, { 98 | usernames, 99 | 'user.fields': USER_PROFILE_FIELDS, 100 | }) 101 | 102 | const twitterUsers = response.data || [] 103 | const lookup = new Map(twitterUsers.map((user) => [user.username, user])) 104 | return lookup 105 | } 106 | 107 | class Twitter { 108 | constructor({ accessTokenKey, accessTokenSecret } = {}) { 109 | // this is used for posting tweets 110 | if (accessTokenKey && accessTokenSecret) { 111 | this.user = createClient({ 112 | accessTokenKey, 113 | accessTokenSecret, 114 | }) 115 | } 116 | } 117 | 118 | async userWithProfileUrl(users) { 119 | if (!users || users.length === 0) { 120 | return [] 121 | } 122 | 123 | try { 124 | const userMap = await getUserLookupMap(users) 125 | return users.map((u) => { 126 | const { profile_image_url } = userMap.get(u.username) || {} 127 | const profileUrl = profile_image_url 128 | ? toBiggerResolutionUrl(profile_image_url) 129 | : TWITTER_DEFAULT_PROFILE_URL 130 | 131 | return { ...u, profileUrl } 132 | }) 133 | } catch (e) { 134 | handleError(e) 135 | } 136 | } 137 | 138 | async getUserProfile(user) { 139 | const profiles = await this.getUserProfiles([user]) 140 | return profiles[0] 141 | } 142 | 143 | async getUserProfiles(users) { 144 | if (!users || users.length === 0) { 145 | return [] 146 | } 147 | 148 | try { 149 | const userMap = await getUserLookupMap(users) 150 | return buildUserProfile(users, userMap) 151 | } catch (e) { 152 | handleError(e) 153 | } 154 | } 155 | 156 | async searchUsers(username, options = {}) { 157 | const { count = 10, page = 0 } = options 158 | try { 159 | const client = createClient({ 160 | accessTokenKey: process.env.TWITTER_ACCESS_TOKEN_KEY, 161 | accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET, 162 | }) 163 | const url = 'users/search' 164 | const response = await client.get(url, { q: username, count, page }) 165 | const users = response.map((u) => ({ username: u.screen_name })) 166 | const profiles = await this.getUserProfiles(users) 167 | return profiles 168 | } catch (e) { 169 | handleError(e) 170 | } 171 | } 172 | 173 | async getRequestToken() { 174 | let token 175 | try { 176 | const client = createClient() 177 | const callbackUrl = process.env.TWITTER_CALLBACK_URL 178 | token = await client.getRequestToken(callbackUrl) 179 | } catch (e) { 180 | handleError(e) 181 | } 182 | 183 | if (!token.oauth_callback_confirmed) { 184 | throw new createError(401, 'OAuth error') 185 | } 186 | 187 | return token 188 | } 189 | 190 | async getAccessToken({ oauthVerifier, oauthToken }) { 191 | const client = createClient() 192 | 193 | const token = await client.getAccessToken({ 194 | oauth_verifier: oauthVerifier, 195 | oauth_token: oauthToken, 196 | }) 197 | 198 | return token 199 | } 200 | 201 | async verifyCredentials() { 202 | if (!this.user) throw new Error('User not authenticated') 203 | const result = await this.user.get('account/verify_credentials') 204 | return { 205 | name: result.name, 206 | username: result.screen_name, 207 | description: result.description, 208 | followersCount: result.followers_count, 209 | profileImage: result.profile_image_url_https, 210 | } 211 | } 212 | 213 | async postTweet(status) { 214 | if (!this.user) throw new Error('User not authenticated') 215 | 216 | try { 217 | const tweet = await this.user.post('statuses/update', { 218 | status: status, 219 | }) 220 | return tweet.id 221 | } catch (e) { 222 | console.log('error tweeting', e) 223 | handleError(e) 224 | } 225 | } 226 | } 227 | 228 | module.exports = Twitter 229 | -------------------------------------------------------------------------------- /packages/web/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals", "prettier"], 3 | "rules": { 4 | // Other rules 5 | "@next/next/no-img-element": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /packages/web/.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /packages/web/components/badge.js: -------------------------------------------------------------------------------- 1 | export default function Badge() { 2 | return ( 3 | 11 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/components/blue-line.js: -------------------------------------------------------------------------------- 1 | export default function BlueLine() { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/components/first-badge.js: -------------------------------------------------------------------------------- 1 | export default function FirstBadge() { 2 | return ( 3 | 11 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 32 | 33 | 34 | 35 | 36 | 44 | 45 | 46 | 47 | 48 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/web/components/game-footer.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { UserContext } from "../lib/UserContext"; 3 | import Letterhead from "./letterhead"; 4 | import Link from "next/link"; 5 | 6 | export default function GameFooter() { 7 | const [user] = useContext(UserContext); 8 | return ( 9 |
10 | 110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /packages/web/components/layout.js: -------------------------------------------------------------------------------- 1 | import GameFooter from "./game-footer"; 2 | import GameMenu from "./game-menu"; 3 | export default function Layout({ children }) { 4 | return ( 5 |
6 | 7 |
{children}
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/components/leaderboard-header.js: -------------------------------------------------------------------------------- 1 | export default function LeaderboardHeader() { 2 | return ( 3 |
4 |
5 |
6 | LEADERBOARD 7 |
8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/components/leaderboard.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import UserCard from "../components/user-card"; 3 | import Pagination from "./pagination"; 4 | 5 | export default function Leaderboard(props) { 6 | const [currentPage, setCurrentPage] = useState(1); 7 | const allData = props.data.data.users; 8 | let PageSize = 10; 9 | const users = useMemo(() => { 10 | const firstPageIndex = (currentPage - 1) * PageSize; 11 | const lastPageIndex = firstPageIndex + PageSize; 12 | return allData.slice(firstPageIndex, lastPageIndex); 13 | }, [currentPage]); 14 | return ( 15 | <> 16 | {users.map((user) => ( 17 |
18 | 19 |
20 | {user.rank > 2 && user.rank < 4 && ( 21 |
22 | )} 23 |
24 |
25 | ))} 26 | setCurrentPage(page)} 31 | /> 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/web/components/pagination.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { usePagination, DOTS } from "../lib/usePagination"; 3 | import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; 4 | 5 | const Pagination = (props) => { 6 | const { 7 | onPageChange, 8 | totalCount, 9 | siblingCount = 1, 10 | currentPage, 11 | pageSize, 12 | } = props; 13 | 14 | const paginationRange = usePagination({ 15 | currentPage, 16 | totalCount, 17 | siblingCount, 18 | pageSize, 19 | }); 20 | 21 | if (currentPage === 0 || paginationRange.length < 2) { 22 | return null; 23 | } 24 | 25 | const onNext = () => { 26 | onPageChange(currentPage + 1); 27 | }; 28 | 29 | const onPrevious = () => { 30 | onPageChange(currentPage - 1); 31 | }; 32 | 33 | let lastPage = paginationRange[paginationRange.length - 1]; 34 | return ( 35 |
36 |
37 | {currentPage === 1 && ( 38 | 45 | )} 46 | {currentPage > 1 && ( 47 | 54 | )} 55 | {currentPage != lastPage && ( 56 | 63 | )} 64 | {currentPage === lastPage && ( 65 | 72 | )} 73 |
74 |
75 |
76 | 153 |
154 |
155 |
156 | ); 157 | }; 158 | 159 | export default Pagination; 160 | -------------------------------------------------------------------------------- /packages/web/components/quadratic-given.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import Link from "next/link"; 3 | import Pagination from "./pagination"; 4 | 5 | export default function QuadraticGiven(props) { 6 | const [currentPage, setCurrentPage] = useState(1); 7 | const allData = props.votes; 8 | let PageSize = 10; 9 | const trustGiven = useMemo(() => { 10 | const firstPageIndex = (currentPage - 1) * PageSize; 11 | const lastPageIndex = firstPageIndex + PageSize; 12 | return allData.slice(firstPageIndex, lastPageIndex); 13 | }, [currentPage, allData]); 14 | 15 | return ( 16 |
17 | {trustGiven.map((trust) => ( 18 |
22 | 23 | 24 |
25 |
26 | {trust.candidate} 31 |
32 |
33 |
34 | @{trust.candidate} 35 |
36 |
37 |
38 |
39 | {trust.score} 40 |
41 |
VOTES
42 |
43 |
44 |
45 | 46 |
47 | ))} 48 | setCurrentPage(page)} 53 | /> 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /packages/web/components/quadratic-received.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import Link from "next/link"; 3 | import Pagination from "./pagination"; 4 | 5 | export default function QuadraticReceived(props) { 6 | const [currentPage, setCurrentPage] = useState(1); 7 | const allData = props.votes; 8 | let PageSize = 10; 9 | const trustReceived = useMemo(() => { 10 | const firstPageIndex = (currentPage - 1) * PageSize; 11 | const lastPageIndex = firstPageIndex + PageSize; 12 | return allData.slice(firstPageIndex, lastPageIndex); 13 | }, [currentPage, allData]); 14 | 15 | return ( 16 |
17 | {trustReceived.map((trust) => ( 18 |
22 | 23 | 24 |
25 |
26 | {trust.voter} 31 |
32 |
33 |
34 | @{trust.voter} 35 |
36 |
37 |
38 |
39 | {trust.score} 40 |
41 |
VOTES
42 |
43 |
44 |
45 | 46 |
47 | ))} 48 | setCurrentPage(page)} 53 | /> 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /packages/web/components/search.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import UserCard from "../components/user-card"; 3 | import Image from "next/image"; 4 | import hero from "../public/hero.svg"; 5 | 6 | export default function Search() { 7 | const [username, setUsername] = useState(""); 8 | const [searchResult, setSearchResult] = useState(null); 9 | const [search, setSearch] = useState(false); 10 | const [noResults, setNoResults] = useState(false); 11 | 12 | const handleSubmit = async (e) => { 13 | e.preventDefault(); 14 | setSearch(true); 15 | const res = await fetch( 16 | `https://quadratictrust.com/api/search/${username}` 17 | ); 18 | const result = await res.json(); 19 | setSearch(false); 20 | setSearchResult(result.users); 21 | if (result.users.length === 0) { 22 | setNoResults(true); 23 | } 24 | }; 25 | 26 | const handleChange = (event) => { 27 | setUsername(event.target.value); 28 | if (username === "") { 29 | setSearchResult(null); 30 | setNoResults(false); 31 | } 32 | }; 33 | return ( 34 | <> 35 |
36 | Hero 37 |
38 |
39 |
40 | 47 | 67 |
68 |
69 |
70 | 77 | 97 |
98 |
99 |
100 | {username && 101 | searchResult && 102 | searchResult.map((searchItem, index) => ( 103 | 104 | ))} 105 | {searchResult === null && search && ( 106 |
107 | 108 | 114 | 120 | 126 | 127 | SEARCHING... 128 |
129 | )} 130 | {username && noResults && ( 131 |
132 | NO RESULTS 133 |
134 | )} 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /packages/web/components/second-badge.js: -------------------------------------------------------------------------------- 1 | export default function SecondBadge() { 2 | return ( 3 | 11 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 32 | 33 | 34 | 35 | 36 | 44 | 45 | 46 | 47 | 48 | 56 | 57 | 58 | 59 | 60 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /packages/web/components/tabs.js: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Tab } from "@headlessui/react"; 3 | import VotesReceived from "./votes-received"; 4 | import VotesGiven from "./votes-given"; 5 | 6 | export default function Tabs(props) { 7 | const username = props.handle; 8 | return ( 9 |
10 | 11 | 12 | 13 | {({ selected }) => ( 14 | 23 | )} 24 | 25 | 26 | {({ selected }) => ( 27 | 36 | )} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /packages/web/components/third-badge.js: -------------------------------------------------------------------------------- 1 | export default function ThirdBadge() { 2 | return ( 3 | 11 | 15 | 19 | 20 | 21 | 22 | 30 | 31 | 32 | 33 | 34 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/web/components/user-card.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Badge from "./badge"; 3 | import FirstBadge from "./first-badge"; 4 | import SecondBadge from "./second-badge"; 5 | import ThirdBadge from "./third-badge"; 6 | import WhiteLine from "./white-line"; 7 | import BlueLine from "./blue-line"; 8 | 9 | export default function UserCard(props) { 10 | const user = props.user; 11 | return ( 12 |
13 |
14 |
15 |
22 |
23 |
24 |
25 | {user.rank == 1 && } 26 | {user.rank == 2 && } 27 | {user.rank == 3 && } 28 | {(user.rank > 3 || user.rank === null) && ( 29 |
30 | 31 |
32 |
39 | {user.rank ? user.rank : "-"} 40 |
41 |
42 |
43 | )} 44 |
45 |
52 | {user.name} 57 |
58 |
65 |
66 | {user.name} 67 |
68 |
69 | @{user.username} 70 |
71 |
72 |
73 |
74 |
81 |
82 | {user.rank < 4 && user.rank != null && } 83 | {user.rank > 3 && user.rank != null && } 84 | {user.rank === null && } 85 |
86 |
87 |
94 |
95 | {user.score ? user.score : "0"} 96 |
97 |
98 | VOTES RECEIVED 99 |
100 |
101 | 102 | 103 | 112 | 113 | 114 |
115 |
116 |
117 |
118 |
119 |
126 |
127 |
128 | {user.rank == 1 && } 129 | {user.rank == 2 && } 130 | {user.rank == 3 && } 131 | {(user.rank > 3 || user.rank === null) && ( 132 |
133 | 134 |
135 |
142 | {user.rank ? user.rank : "-"} 143 |
144 |
145 |
146 | )} 147 |
148 |
155 | 160 |
161 |
162 | 163 | 164 | 173 | 174 | 175 |
176 |
177 |
184 |
{user.name}
185 |
186 |
193 | @{user.username} 194 |
195 |
196 |
203 |
204 |
205 | {user.score ? user.score : "0"} 206 |
207 |
208 | VOTES RECEIVED 209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 | ); 218 | } 219 | -------------------------------------------------------------------------------- /packages/web/components/votes-given.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { CastContext } from "../lib/CastContext"; 3 | import { TwitterContext } from "../lib/TwitterContext"; 4 | import QuadraticGiven from "./quadratic-given"; 5 | import getData from "../../web/lib/utils"; 6 | 7 | export default function VotesGiven(props) { 8 | const voter = props.voter; 9 | const [error, setError] = useState(null); 10 | const [isLoading, setIsLoading] = useState(true); 11 | const [given, setGiven] = useState(null); 12 | const [cast] = useContext(CastContext); 13 | const [twitterHandle] = useContext(TwitterContext); 14 | 15 | useEffect(() => { 16 | getData(`https://quadratictrust.com/api/ballots?voter=${voter}`) 17 | .then((data) => { 18 | setGiven(data.data.ballots); 19 | setIsLoading(false); 20 | }) 21 | .catch((error) => { 22 | setError(error); 23 | setIsLoading(false); 24 | }); 25 | }, [voter, cast, twitterHandle]); 26 | 27 | if (error) { 28 | return ( 29 |
30 | {error.message} 31 |
32 | ); 33 | } 34 | 35 | if (isLoading) { 36 | return ( 37 |
38 | 39 | 45 | 51 | 57 | 58 | COUNTING VOTES... 59 |
60 | ); 61 | } 62 | 63 | if (given.length === 0) { 64 | return ( 65 |
66 | NO VOTES YET 67 |
68 | ); 69 | } 70 | 71 | return ( 72 |
73 | {given.length != 0 && } 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /packages/web/components/votes-received.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { CastContext } from "../lib/CastContext"; 3 | import { TwitterContext } from "../lib/TwitterContext"; 4 | import QuadraticReceived from "../components/quadratic-received"; 5 | import getData from "../../web/lib/utils"; 6 | 7 | export default function VotesReceived(props) { 8 | const candidate = props.candidate; 9 | const [error, setError] = useState(null); 10 | const [isLoading, setIsLoading] = useState(true); 11 | const [received, setReceived] = useState(null); 12 | const [cast] = useContext(CastContext); 13 | const [twitterHandle] = useContext(TwitterContext); 14 | 15 | useEffect(() => { 16 | getData(`https://quadratictrust.com/api/ballots?candidate=${candidate}`) 17 | .then((data) => { 18 | setReceived(data.data.ballots); 19 | setIsLoading(false); 20 | }) 21 | .catch((error) => { 22 | setError(error); 23 | setIsLoading(false); 24 | }); 25 | }, [candidate, cast, twitterHandle]); 26 | 27 | if (error) { 28 | return ( 29 |
30 | {error.message} 31 |
32 | ); 33 | } 34 | 35 | if (isLoading) { 36 | return ( 37 |
38 | 39 | 45 | 51 | 57 | 58 | COUNTING VOTES... 59 |
60 | ); 61 | } 62 | 63 | if (received.length === 0) { 64 | return ( 65 |
66 | NO VOTES YET 67 |
68 | ); 69 | } 70 | 71 | return ( 72 |
73 | {received.length != 0 && } 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /packages/web/components/white-line.js: -------------------------------------------------------------------------------- 1 | export default function WhiteLine() { 2 | return ( 3 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/lib/CastContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const CastContext = createContext(false); 4 | -------------------------------------------------------------------------------- /packages/web/lib/LoggedContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const LoggedContext = createContext(false); 4 | -------------------------------------------------------------------------------- /packages/web/lib/TwitterContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const TwitterContext = createContext(""); 4 | -------------------------------------------------------------------------------- /packages/web/lib/UserContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const UserContext = createContext(null); 4 | -------------------------------------------------------------------------------- /packages/web/lib/usePagination.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | export const DOTS = "..."; 4 | 5 | const range = (start, end) => { 6 | let length = end - start + 1; 7 | return Array.from({ length }, (_, idx) => idx + start); 8 | }; 9 | 10 | export const usePagination = ({ 11 | totalCount, 12 | pageSize, 13 | siblingCount = 1, 14 | currentPage, 15 | }) => { 16 | const paginationRange = useMemo(() => { 17 | const totalPageCount = Math.ceil(totalCount / pageSize); 18 | const totalPageNumbers = siblingCount + 5; 19 | 20 | if (totalPageNumbers >= totalPageCount) { 21 | return range(1, totalPageCount); 22 | } 23 | 24 | const leftSiblingIndex = Math.max(currentPage - siblingCount, 1); 25 | const rightSiblingIndex = Math.min( 26 | currentPage + siblingCount, 27 | totalPageCount 28 | ); 29 | 30 | const shouldShowLeftDots = leftSiblingIndex > 2; 31 | const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2; 32 | 33 | const firstPageIndex = 1; 34 | const lastPageIndex = totalPageCount; 35 | 36 | if (!shouldShowLeftDots && shouldShowRightDots) { 37 | let leftItemCount = 3 + 2 * siblingCount; 38 | let leftRange = range(1, leftItemCount); 39 | 40 | return [...leftRange, DOTS, totalPageCount]; 41 | } 42 | 43 | if (shouldShowLeftDots && !shouldShowRightDots) { 44 | let rightItemCount = 3 + 2 * siblingCount; 45 | let rightRange = range( 46 | totalPageCount - rightItemCount + 1, 47 | totalPageCount 48 | ); 49 | return [firstPageIndex, DOTS, ...rightRange]; 50 | } 51 | 52 | if (shouldShowLeftDots && shouldShowRightDots) { 53 | let middleRange = range(leftSiblingIndex, rightSiblingIndex); 54 | return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]; 55 | } 56 | }, [totalCount, pageSize, siblingCount, currentPage]); 57 | 58 | return paginationRange; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/web/lib/utils.js: -------------------------------------------------------------------------------- 1 | export default function getData(url) { 2 | return fetch(url).then((resp) => { 3 | if (!resp.ok) { 4 | throw Error("There was a problem fetching data"); 5 | } 6 | return resp.json(); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/next-seo.config: -------------------------------------------------------------------------------- 1 | const title = 'Quadratic Trust'; 2 | const description ='Use your social capital to support the people you trust'; 3 | const SEO = { 4 | title, 5 | description, 6 | canonical: 'https://quadratictrust.com', 7 | openGraph: { 8 | type: 'website', 9 | locale: 'en', 10 | url: 'https://quadratictrust.com', 11 | title, 12 | description, 13 | images: [ 14 | { 15 | url: 'https://quadratictrust.com/images/banner.png', 16 | width: 1200, 17 | height: 630, 18 | alt: title 19 | } 20 | ], 21 | site_name: 'quadratictrust' 22 | }, 23 | twitter: { 24 | handle: '@gitcoinco', 25 | site: '@gitcoinco', 26 | cardType: 'summary_large_image' 27 | } 28 | }; 29 | 30 | export default SEO; -------------------------------------------------------------------------------- /packages/web/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | images: { 4 | domains: ["pbs.twimg.com"], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@headlessui/react": "^1.3.0", 12 | "@heroicons/react": "^1.0.2", 13 | "@tailwindcss/forms": "^0.3.3", 14 | "next": "11.0.1", 15 | "next-seo": "^4.26.0", 16 | "react": "17.0.2", 17 | "react-dom": "17.0.2" 18 | }, 19 | "devDependencies": { 20 | "autoprefixer": "^10.2.6", 21 | "eslint": "7.30.0", 22 | "eslint-config-next": "11.0.1", 23 | "eslint-config-prettier": "^8.3.0", 24 | "postcss": "^8.3.5", 25 | "prettier": "^2.3.2", 26 | "tailwindcss": "^2.2.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/pages/404.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import sparks from "../public/trusty404.svg"; 4 | export default function Custom404() { 5 | return ( 6 |
7 |

8 | 404 error 9 |

10 |

11 | Uh oh! Don't lose your trust. 12 |

13 | Trust Sparks 14 |

15 | It looks like the page you’re looking for doesn't exist. 16 |

17 |
18 | 19 | 20 | Go back home 21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/web/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "tailwindcss/tailwind.css"; 2 | import { useEffect, useState } from "react"; 3 | import { useRouter } from "next/router"; 4 | import { UserContext } from "../lib/UserContext"; 5 | import { LoggedContext } from "../lib/LoggedContext"; 6 | import { CastContext } from "../lib/CastContext"; 7 | import { TwitterContext } from "../lib/TwitterContext"; 8 | import Layout from "../components/layout"; 9 | import Head from "next/head"; 10 | import { DefaultSeo } from "next-seo"; 11 | import SEO from "../next-seo.config"; 12 | 13 | export default function MyApp({ Component, pageProps }) { 14 | const router = useRouter(); 15 | const [user, setUser] = useState(); 16 | const [enabled, setEnabled] = useState(); 17 | const [cast, setCast] = useState(); 18 | const [twitterHandle, setTwitterHandle] = useState(); 19 | const handleUser = async () => { 20 | const res = await fetch(`https://quadratictrust.com/api/identity`); 21 | const result = await res.json(); 22 | setUser(result); 23 | if (result.username != null) { 24 | setEnabled(true); 25 | } else { 26 | setEnabled(false); 27 | } 28 | }; 29 | 30 | useEffect(() => { 31 | setUser({ loading: true }); 32 | handleUser(); 33 | const handleRouteChange = (url) => { 34 | window.gtag("config", "G-EYNMLSETH4", { page_path: url }); 35 | }; 36 | router.events.on("routeChangeComplete", handleRouteChange); 37 | return () => { 38 | router.events.off("routeChangeComplete", handleRouteChange); 39 | }; 40 | }, [router.events]); 41 | return ( 42 | <> 43 | 44 | 45 | 46 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/web/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx) { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | return { ...initialProps }; 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 35 | 41 | 46 | 52 | 58 | 63 |