├── vue ├── .nvmrc ├── .browserlintrc ├── public │ ├── robots.txt │ ├── og.png │ ├── logo.png │ ├── favicon.png │ ├── devternity.png │ ├── logo_dark.png │ ├── speakers │ │ ├── jbrains.jpg │ │ ├── guy_steele.jpg │ │ ├── russ_olsen.jpg │ │ ├── ryan_dahl.png │ │ ├── seriouspony.png │ │ ├── matt_abrahams.jpg │ │ └── james_hamilton.png │ ├── btn_google_signin_light_focus_web@2x.png │ ├── btn_google_signin_light_normal_web@2x.png │ ├── btn_google_signin_light_pressed_web@2x.png │ ├── index.html │ ├── logo.svg │ ├── logo_dark.svg │ ├── privacy.html │ └── 404.html ├── vue.config.js ├── .babelrc ├── src │ ├── helpers │ │ ├── datetime.js │ │ ├── filters.js │ │ └── videos.js │ ├── App.vue │ ├── api │ │ └── index.js │ ├── RelatedVideos.vue │ ├── WatchingNow.vue │ ├── Categories.vue │ ├── TwitterThanks.vue │ ├── Footer.vue │ ├── MagicCircle.vue │ ├── styles │ │ └── main.scss │ ├── edit │ │ └── index.js │ ├── VideoComments.vue │ ├── Login.vue │ ├── user │ │ └── index.js │ ├── auth │ │ └── index.js │ ├── VideoComment.vue │ ├── EditVideo.vue │ ├── VideoCard.vue │ ├── VideoCommentEditor.vue │ ├── NavBar.vue │ ├── Banner.vue │ ├── main.js │ ├── Watch.vue │ ├── VideoActions.vue │ ├── Search.vue │ └── SubmitVideo.vue ├── .firebaserc ├── .eslintrc.js ├── firebase.json └── package.json ├── worker ├── .cargo-ok ├── wrangler.toml ├── package.json ├── package-lock.json └── index.js ├── express ├── .gcloudignore ├── .gitignore ├── src │ ├── libs │ │ ├── Stats.js │ │ ├── Middlewares.js │ │ ├── Datastore.js │ │ ├── Youtube.js │ │ └── Cookies.js │ └── routes │ │ ├── stats.js │ │ ├── index.js │ │ ├── tweets.test.js │ │ ├── rss.js │ │ ├── og.js │ │ ├── sitemap.js │ │ ├── tweets.js │ │ ├── videos.js │ │ ├── youtube.js │ │ ├── user.js │ │ ├── search.js │ │ └── auth.js ├── reindex-run.js ├── index.yaml ├── reindex-impl.js ├── index.js └── package.json ├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── README.md └── .github └── workflows └── server-deploy.yml /vue/.nvmrc: -------------------------------------------------------------------------------- 1 | v16 2 | -------------------------------------------------------------------------------- /worker/.cargo-ok: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vue/.browserlintrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /vue/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /vue/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/og.png -------------------------------------------------------------------------------- /vue/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/logo.png -------------------------------------------------------------------------------- /vue/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/favicon.png -------------------------------------------------------------------------------- /vue/public/devternity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/devternity.png -------------------------------------------------------------------------------- /vue/public/logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/logo_dark.png -------------------------------------------------------------------------------- /vue/public/speakers/jbrains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/jbrains.jpg -------------------------------------------------------------------------------- /vue/public/speakers/guy_steele.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/guy_steele.jpg -------------------------------------------------------------------------------- /vue/public/speakers/russ_olsen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/russ_olsen.jpg -------------------------------------------------------------------------------- /vue/public/speakers/ryan_dahl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/ryan_dahl.png -------------------------------------------------------------------------------- /vue/public/speakers/seriouspony.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/seriouspony.png -------------------------------------------------------------------------------- /vue/public/speakers/matt_abrahams.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/matt_abrahams.jpg -------------------------------------------------------------------------------- /vue/public/speakers/james_hamilton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/speakers/james_hamilton.png -------------------------------------------------------------------------------- /express/.gcloudignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | yarn* 3 | service_key.json 4 | tsconfig.json 5 | ./src/ 6 | *.yml 7 | *.enc 8 | *.ts 9 | .serverless/ -------------------------------------------------------------------------------- /vue/public/btn_google_signin_light_focus_web@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_focus_web@2x.png -------------------------------------------------------------------------------- /vue/public/btn_google_signin_light_normal_web@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_normal_web@2x.png -------------------------------------------------------------------------------- /vue/public/btn_google_signin_light_pressed_web@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watch-devtube/web/HEAD/vue/public/btn_google_signin_light_pressed_web@2x.png -------------------------------------------------------------------------------- /vue/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | hot: true, 4 | host: "devtube.xxx", 5 | disableHostCheck: true, 6 | port: 8080 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /express/.gitignore: -------------------------------------------------------------------------------- 1 | !webpack.config.js 2 | !datastore.init.js 3 | *.pem 4 | service_key.json 5 | firebase.json 6 | .env 7 | .serverless/ 8 | .build/ 9 | dist/ 10 | lib/ 11 | data/ 12 | data.zip 13 | -------------------------------------------------------------------------------- /worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "worker-devtube" 2 | account_id = "3ef2bbbd14c3c8946513f654cb9889b5" 3 | workers_dev = false 4 | route = "dev.tube/*" 5 | main = "index.js" 6 | compatibility_date = "2022-04-05" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | reindex/ 4 | backup/ 5 | dist/ 6 | src/static/*.xml 7 | service_key.json 8 | datastore_key.json 9 | firebase.config.json 10 | .idea/ 11 | .firebase 12 | *.log 13 | -------------------------------------------------------------------------------- /vue/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@vue/cli-plugin-babel/preset"], 3 | "plugins": [ 4 | // "syntax-dynamic-import", 5 | "@babel/plugin-proposal-object-rest-spread", 6 | "@babel/plugin-proposal-optional-chaining" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /vue/src/helpers/datetime.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import duration from "dayjs/plugin/duration"; 3 | import relativeTime from "dayjs/plugin/relativeTime"; 4 | dayjs.extend(duration); 5 | dayjs.extend(relativeTime); 6 | 7 | export { dayjs }; 8 | -------------------------------------------------------------------------------- /express/src/libs/Stats.js: -------------------------------------------------------------------------------- 1 | const memoize = require('memoizee'); 2 | const { readFileSync } = require("fs"); 3 | 4 | const readStats = () => JSON.parse(readFileSync('./data/stats.json', { encoding: "utf8" })); 5 | const statsForever = memoize(readStats, { promise: true }); 6 | 7 | module.exports.statsForever = statsForever -------------------------------------------------------------------------------- /vue/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "dev-tube" 4 | }, 5 | "targets": { 6 | "dev-tube": { 7 | "hosting": { 8 | "devtube": [ 9 | "dev-tube" 10 | ], 11 | "devtube-api": [ 12 | "devtube-api" 13 | ] 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "worker", 4 | "version": "1.0.0", 5 | "description": "A template for kick starting a Cloudflare Workers project", 6 | "main": "index.js", 7 | "author": "Eduards Sizovs", 8 | "license": "MIT", 9 | "dependencies": {}, 10 | "volta": { 11 | "node": "18.20.8" 12 | } 13 | } -------------------------------------------------------------------------------- /express/src/routes/stats.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require('express-async-handler') 2 | const router = require("express").Router(); 3 | const { statsForever } = require("../libs/Stats") 4 | 5 | router.get("/", asyncHandler(async (_req, res) => { 6 | const stats = await statsForever(); 7 | res.json(stats); 8 | })); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /vue/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["plugin:vue/essential", "@vue/prettier"], 7 | rules: { 8 | "no-console": "off", 9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" 10 | }, 11 | parserOptions: { 12 | parser: "babel-eslint" 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.autofetch": true, 3 | "editor.renderControlCharacters": false, 4 | "git.enableSmartCommit": true, 5 | "editor.tabSize": 2, 6 | "editor.insertSpaces": true, 7 | "editor.detectIndentation": false, 8 | "editor.formatOnSave": true, 9 | "vetur.validation.template": false, 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /worker/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "prettier": { 8 | "version": "1.19.1", 9 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", 10 | "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", 11 | "dev": true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 20 | -------------------------------------------------------------------------------- /express/src/libs/Middlewares.js: -------------------------------------------------------------------------------- 1 | const returnBack = (req, _res, next) => { 2 | req.session.returnTo = req.query.returnTo 3 | next(); 4 | } 5 | 6 | const authenticated = (req, res, next) => { 7 | if (!req.user) { 8 | res.sendStatus(403) 9 | return; 10 | } 11 | next(); 12 | } 13 | 14 | const adminsOnly = (req, res, next) => { 15 | if (!req.user || !req.user.admin) { 16 | res.sendStatus(403) 17 | return; 18 | } 19 | next(); 20 | } 21 | 22 | module.exports.authenticated = authenticated; 23 | module.exports.returnBack = returnBack; 24 | module.exports.adminsOnly = adminsOnly; 25 | 26 | -------------------------------------------------------------------------------- /express/src/routes/index.js: -------------------------------------------------------------------------------- 1 | function initRoute(app, route, requirePath) { 2 | console.time(route + " init") 3 | app.use(route, require(requirePath)); 4 | console.timeEnd(route + " init") 5 | } 6 | 7 | module.exports = app => { 8 | initRoute(app, "/api/s", "./search"); 9 | initRoute(app, "/api/videos", "./videos"); 10 | initRoute(app, "/api/auth", "./auth"); 11 | initRoute(app, "/api/stats", "./stats"); 12 | initRoute(app, "/api/sitemap", "./sitemap"); 13 | initRoute(app, "/api/rss", "./rss"); 14 | initRoute(app, "/api/og", "./og"); 15 | initRoute(app, "/api/user", "./user"); 16 | initRoute(app, "/api/tweets", "./tweets"); 17 | initRoute(app, "/api/youtube", "./youtube"); 18 | }; -------------------------------------------------------------------------------- /express/reindex-run.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | const { processVideos } = require("./src/libs/Datastore") 3 | const { begin, each, end } = require("./reindex-impl") 4 | const { writeFileSync } = require("fs"); 5 | 6 | const data = begin(); 7 | const approvedVideos = [] 8 | const mapper = (video) => { 9 | const newVideo = each(video, data); 10 | if (newVideo.status === 'approved') { 11 | approvedVideos.push(newVideo) 12 | } 13 | 14 | } 15 | const done = () => { 16 | const stats = end(data); 17 | writeFileSync('./data/stats.json', JSON.stringify(stats)); 18 | writeFileSync('./data/videos.json', JSON.stringify(approvedVideos)); 19 | } 20 | 21 | processVideos(mapper, done); 22 | })(); 23 | 24 | 25 | -------------------------------------------------------------------------------- /vue/src/helpers/filters.js: -------------------------------------------------------------------------------- 1 | import { dayjs } from "./datetime"; 2 | 3 | export const durationFull = it => { 4 | if (!it) { 5 | return it; 6 | } 7 | 8 | const videoDuration = dayjs.duration(it); 9 | const hours = videoDuration.hours(); 10 | const minutes = videoDuration.minutes(); 11 | return hours ? `${hours} hour(s)` : `${minutes} minutes`; 12 | }; 13 | 14 | export const published = it => (it ? dayjs(it).format("MMM YYYY") : it); 15 | export const year = it => (it ? dayjs(it).format("YYYY") : it); 16 | 17 | export const years = () => { 18 | const yearNow = dayjs().year(); 19 | const yearNext = dayjs() 20 | .add(1, "year") 21 | .year(); 22 | return `${yearNow}/${yearNext}`; 23 | }; 24 | 25 | export const ago = it => { 26 | return dayjs(it).fromNow(); 27 | }; 28 | -------------------------------------------------------------------------------- /vue/src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Vue from "vue"; 3 | 4 | export const bucket = axios.create({ 5 | baseURL: "//storage.googleapis.com/dev-tube-index" 6 | }); 7 | 8 | export const apiUrl = window.location.href.includes("devtube.xxx") 9 | ? "//devtube.xxx:8100/api/" 10 | : "//dev.tube/api/"; 11 | 12 | export const api = axios.create({ 13 | baseURL: apiUrl, 14 | timeout: 10000, 15 | withCredentials: true 16 | }); 17 | 18 | api.interceptors.response.use( 19 | response => response, 20 | error => { 21 | Vue.notify({ 22 | group: "notification", 23 | title: "Oops! Something went wrong.", 24 | type: "error", 25 | text: "Please see logs and submit an issue: bit.ly/devtube-issue" 26 | }); 27 | return Promise.reject(error); 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /express/index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | - kind: videos 3 | properties: 4 | - name: status 5 | - name: likes 6 | direction: desc 7 | 8 | - kind: videos 9 | properties: 10 | - name: topics 11 | - name: likes 12 | direction: desc 13 | 14 | - kind: videos 15 | properties: 16 | - name: speakerTwitters 17 | - name: likes 18 | direction: desc 19 | 20 | - kind: videos 21 | properties: 22 | - name: status 23 | - name: submissionDate 24 | direction: desc 25 | 26 | - kind: videos 27 | properties: 28 | - name: topics 29 | - name: submissionDate 30 | direction: desc 31 | 32 | - kind: videos 33 | properties: 34 | - name: speakerTwitters 35 | - name: submissionDate 36 | direction: desc 37 | -------------------------------------------------------------------------------- /worker/index.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | async function handleRequest(request) { 6 | const { url, headers } = request 7 | 8 | const userAgent = headers.get('user-agent') || '' 9 | const bots = ["Twitterbot", "Slackbot", "LinkedIn"]; 10 | 11 | if (url.includes('https://dev.tube/video/') && bots.some(bot => userAgent.includes(bot))) { 12 | const redirectUrl = url.replace('https://dev.tube/video/', 'https://dev.tube/api/og/') 13 | return Response.redirect(redirectUrl, 302) 14 | } 15 | 16 | if (url.includes('dev.tube/sitemap.xml')) { 17 | return Response.redirect('https://dev.tube/api/sitemap', 302) 18 | } else if (url.includes('dev.tube/rss.xml')) { 19 | return Response.redirect('https://dev.tube/api/rss', 302) 20 | } else { 21 | return await fetch(request) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /express/src/routes/tweets.test.js: -------------------------------------------------------------------------------- 1 | const Twit = require("twit"); 2 | const { tweetTrending } = require("./tweets"); 3 | 4 | jest.mock("Twit"); 5 | 6 | const [twitter] = Twit.mock.instances; 7 | twitter.post 8 | .mockName("post") 9 | .mockReturnValue(Promise.resolve("OK")); 10 | 11 | afterEach(() => { 12 | jest.clearAllMocks(); 13 | }); 14 | 15 | 16 | test("tweet a trending video", async () => { 17 | const video = { 18 | "likes": 1501, 19 | "objectID": "AEtCEt44vlE", 20 | "speakerTwitters": [ 21 | "eduardsi" 22 | ], 23 | "title": "Beyond Software Craftsmanship" 24 | } 25 | 26 | const watchingNow = 10 27 | const comments = 3400 28 | await tweetTrending(video, watchingNow, comments); 29 | expect(twitter.post).toHaveBeenCalledWith("statuses/update", { 30 | status: `@eduardsi: 31 | 📈 Your talk "Beyond Software Craftsmanship" is trending on DevTube: 32 | ❤️ 1K likes 33 | ✍️ 3K comments 34 | 📺 10 watching now 35 | 36 | https://dev.tube/video/AEtCEt44vlE`, 37 | }) 38 | }); -------------------------------------------------------------------------------- /vue/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": [ 3 | { 4 | "target": "devtube", 5 | "public": "dist", 6 | "ignore": [ 7 | "firebase.json", 8 | "**/.*", 9 | "**/node_modules/**" 10 | ], 11 | "rewrites": [ 12 | { 13 | "source": "/api/**", 14 | "function": "api" 15 | }, 16 | { 17 | "source": "**", 18 | "destination": "/index.html" 19 | } 20 | ], 21 | "headers": [ 22 | { 23 | "source": "**/*.@(js|css|png)", 24 | "headers": [ 25 | { 26 | "key": "Cache-Control", 27 | "value": "max-age=31536000" 28 | } 29 | ] 30 | } 31 | ] 32 | }, 33 | { 34 | "target": "devtube-api", 35 | "public": "dist", 36 | "ignore": [ 37 | "**" 38 | ], 39 | "rewrites": [ 40 | { 41 | "source": "**", 42 | "function": "api" 43 | } 44 | ] 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /vue/src/helpers/videos.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import relativeTime from "dayjs/plugin/relativeTime"; 3 | import utc from "dayjs/plugin/utc"; 4 | dayjs.extend(utc); 5 | dayjs.extend(relativeTime); 6 | 7 | export function avatar(video, speakerIndex) { 8 | const customAvatars = { 9 | seriouspony: "/speakers/seriouspony.png", 10 | spchteach: "/speakers/matt_abrahams.jpg", 11 | jrhatmvdirona: "/speakers/james_hamilton.png", 12 | guysteele: "/speakers/guy_steele.jpg", 13 | ryanmdahl: "/speakers/ryan_dahl.png", 14 | jbrains: "/speakers/jbrains.jpg", 15 | russolsen: "/speakers/russ_olsen.jpg" 16 | }; 17 | const speaker = video.speakerTwitters[speakerIndex]; 18 | return customAvatars[speaker] || `https://unavatar.io/twitter/${speaker}`; 19 | } 20 | 21 | export function addedAgo(video) { 22 | return "added " + dayjs(video.submissionDate).fromNow(); 23 | } 24 | 25 | export function expiresIn() { 26 | const expires = dayjs 27 | .utc() 28 | .endOf("week") 29 | .fromNow(); 30 | if (expires.includes("ago")) { 31 | return "soon"; 32 | } else { 33 | return expires; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /express/reindex-impl.js: -------------------------------------------------------------------------------- 1 | module.exports.begin = () => { 2 | const data = { 3 | speakerStats: {}, 4 | speakerNames: {}, 5 | topicStats: {}, 6 | karma: {} 7 | } 8 | return data; 9 | } 10 | 11 | module.exports.each = (video, data) => { 12 | data.karma[video.contributor] = (data.karma[video.contributor] || 0) + (video.likes || 1) 13 | video.speakerTwitters.forEach((twitter, index) => { 14 | data.speakerStats[twitter] = (data.speakerStats[twitter] || 0) + 1 15 | data.speakerNames[twitter] = video.speakerNames[index] 16 | }) 17 | 18 | video.topics.forEach(topic => { 19 | data.topicStats[topic] = (data.topicStats[topic] || 0) + 1 20 | }) 21 | 22 | return video; 23 | }; 24 | 25 | module.exports.end = (data) => { 26 | 27 | const topics = Object.entries(data.topicStats).map(([key, count]) => ({ key, count })); 28 | topics.sort((a, b) => a.key.localeCompare(b.key)) 29 | 30 | 31 | const speakers = Object.entries(data.speakerStats).map(([key, count]) => ({ key, count, name: data.speakerNames[key] })) 32 | speakers.sort((a, b) => a.name.localeCompare(b.key)) 33 | 34 | const stats = { 35 | topics, 36 | speakers, 37 | karma: data.karma 38 | } 39 | 40 | return stats; 41 | } -------------------------------------------------------------------------------- /express/src/routes/rss.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | const router = require("express").Router(); 3 | const { searchApprovedVideosForever } = require("../libs/Datastore"); 4 | 5 | router.get( 6 | "/", 7 | asyncHandler(async (req, res) => { 8 | console.time("rss fetch"); 9 | const videos = await searchApprovedVideosForever(); 10 | console.timeEnd("rss fetch"); 11 | 12 | const feedTitle = "Best tech talks for developers"; 13 | 14 | const toFeedItem = (video) => ({ 15 | title: video.title, 16 | id: video.objectID, 17 | link: `https://dev.tube/video/${video.objectID}`, 18 | description: `${video.title} by ${video.speakerNames.join(", ")}`, 19 | image: `https://img.youtube.com/vi/${video.objectID}/hqdefault.jpg`, 20 | }); 21 | 22 | const { Feed } = require("feed"); 23 | const feed = new Feed({ 24 | title: feedTitle, 25 | description: feedTitle, 26 | link: "https://dev.tube", 27 | image: `https://dev.tube/favicon.png`, 28 | }); 29 | videos.map(toFeedItem).forEach(feed.addItem); 30 | 31 | res.set("Content-Type", "text/xml"); 32 | res.send(feed.rss2()); 33 | }) 34 | ); 35 | 36 | module.exports = router; -------------------------------------------------------------------------------- /vue/src/RelatedVideos.vue: -------------------------------------------------------------------------------- 1 | 9 | 45 | -------------------------------------------------------------------------------- /vue/src/WatchingNow.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 38 | -------------------------------------------------------------------------------- /vue/src/Categories.vue: -------------------------------------------------------------------------------- 1 | 16 | 34 | 45 | -------------------------------------------------------------------------------- /vue/src/TwitterThanks.vue: -------------------------------------------------------------------------------- 1 | 11 | 47 | -------------------------------------------------------------------------------- /vue/src/Footer.vue: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /express/src/routes/og.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | const router = require("express").Router(); 3 | const { oneVideo } = require("../libs/Datastore"); 4 | 5 | router.get( 6 | "/:videoId", 7 | asyncHandler(async (req, res) => { 8 | const { videoId } = req.params; 9 | if (!videoId) { 10 | res.sendStatus(500) 11 | return; 12 | } 13 | 14 | const video = await oneVideo(videoId); 15 | const ogLink = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` 16 | 17 | const markup = ` 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | `; 32 | res.send(markup) 33 | 34 | }) 35 | ); 36 | 37 | module.exports = router; 38 | -------------------------------------------------------------------------------- /vue/src/MagicCircle.vue: -------------------------------------------------------------------------------- 1 | 7 | 17 | 51 | -------------------------------------------------------------------------------- /vue/src/styles/main.scss: -------------------------------------------------------------------------------- 1 | $info: #4988ca; 2 | $link: #4988ca; 3 | 4 | .select::after { 5 | border-color: hsl(0, 0%, 86%) !important; 6 | } 7 | 8 | .select select { 9 | border: none !important; 10 | } 11 | 12 | .button.is-text { 13 | background: none !important; 14 | } 15 | 16 | .is-borderless { 17 | border: none !important; 18 | } 19 | 20 | .is-transparent { 21 | background-color: transparent !important; 22 | } 23 | 24 | $card-radius: 25px !important; 25 | .card { 26 | background-color: black !important; 27 | padding: 3rem !important; 28 | margin-bottom: 3rem !important; 29 | } 30 | 31 | .window { 32 | margin-top: 5rem !important; 33 | background: white !important; 34 | border-radius: 10px !important; 35 | padding: 4.5rem !important; 36 | } 37 | 38 | @import "~bulma"; 39 | @import "vue-loading-overlay/dist/vue-loading"; 40 | 41 | .clearfix { 42 | @include clearfix; 43 | } 44 | 45 | @include mobile { 46 | .card { 47 | padding: 2rem !important; 48 | } 49 | } 50 | 51 | @include mobile { 52 | .window { 53 | padding: 2.5rem !important; 54 | } 55 | } 56 | 57 | .is-loadable { 58 | &.is-loading { 59 | position: relative; 60 | pointer-events: none; 61 | opacity: 0.5; 62 | &:after { 63 | @include loader; 64 | position: absolute; 65 | top: calc(50% - 2.5em); 66 | left: calc(50% - 2.5em); 67 | width: 5em; 68 | height: 5em; 69 | border-width: 0.25em; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /vue/src/edit/index.js: -------------------------------------------------------------------------------- 1 | import { api } from "../api"; 2 | 3 | const state = { 4 | popupVisible: false, 5 | video: undefined, 6 | speakers: [] 7 | }; 8 | 9 | const getters = { 10 | isPopupVisible: state => state.popupVisible 11 | }; 12 | 13 | const actions = { 14 | saveVideo({ state, dispatch }, { tweet }) { 15 | const newVideo = JSON.parse(state.video); 16 | const videoId = newVideo.objectID; 17 | api 18 | .put("/videos/" + videoId, { newVideo, tweet }) 19 | .then(() => dispatch("hidePopup")); 20 | }, 21 | showPopup({ commit }, videoId) { 22 | api.get("/videos/" + videoId + "/edit").then(({ data }) => { 23 | commit("editReady", data); 24 | commit("popupVisible", true); 25 | const classes = document.documentElement.classList; 26 | classes?.add("is-clipped"); 27 | }); 28 | }, 29 | hidePopup({ commit }) { 30 | commit("popupVisible", false); 31 | const classes = document.documentElement.classList; 32 | classes?.remove("is-clipped"); 33 | } 34 | }; 35 | 36 | const mutations = { 37 | editVideo: (state, data) => { 38 | state.video = data; 39 | }, 40 | editReady: (state, { video, speakers }) => { 41 | state.video = video; 42 | state.speakers = speakers; 43 | }, 44 | popupVisible: (state, visible) => { 45 | state.popupVisible = visible; 46 | } 47 | }; 48 | 49 | export default { 50 | namespaced: true, 51 | state, 52 | getters, 53 | actions, 54 | mutations 55 | }; 56 | -------------------------------------------------------------------------------- /express/src/routes/sitemap.js: -------------------------------------------------------------------------------- 1 | console.time("initializing sitemap"); 2 | const asyncHandler = require("express-async-handler"); 3 | const router = require("express").Router(); 4 | const { searchApprovedVideosForever } = require("../libs/Datastore"); 5 | console.timeEnd("initializing sitemap"); 6 | 7 | router.get( 8 | "/", 9 | asyncHandler(async (_req, res) => { 10 | const { SitemapStream } = require("sitemap"); 11 | const { createGzip } = require("zlib"); 12 | 13 | res.header("Content-Type", "application/xml"); 14 | res.header("Content-Encoding", "gzip"); 15 | 16 | const links = new Set(); 17 | const videos = await searchApprovedVideosForever(); 18 | 19 | videos.forEach((video) => { 20 | links.add("/video/" + video.objectID); 21 | video.topics.forEach((topic) => { 22 | links.add("/~" + encodeURIComponent(topic)); 23 | }); 24 | video.speakerTwitters.forEach((speaker) => { 25 | links.add("/@" + speaker); 26 | }); 27 | }); 28 | 29 | try { 30 | const stream = new SitemapStream({ hostname: "https://dev.tube/" }); 31 | const pipeline = stream.pipe(createGzip()); 32 | links.forEach((link) => stream.write({ url: link, changefreq: "daily" })); 33 | stream.end(); 34 | pipeline.pipe(res).on("error", (e) => { 35 | throw e; 36 | }); 37 | } catch (e) { 38 | console.error(e); 39 | res.status(500).end(); 40 | } 41 | }) 42 | ); 43 | 44 | module.exports = router; -------------------------------------------------------------------------------- /express/index.js: -------------------------------------------------------------------------------- 1 | const dnsCache = require("dnscache"); 2 | const isDevMode = process.env.MODE === "dev" 3 | require('dotenv').config() 4 | 5 | console.time("Cold start") 6 | 7 | dnsCache({ 8 | enable: true, 9 | ttl: 300, 10 | cachesize: 1000, 11 | }); 12 | 13 | const express = require("express"); 14 | const morgan = require('morgan'); 15 | const passport = require("passport"); 16 | const body = require("body-parser"); 17 | const cors = require("cors"); 18 | const cookieSession = require("./src/libs/Cookies"); 19 | const app = express(); 20 | const port = process.env.PORT || 8100; 21 | 22 | app.use(cookieSession({ 23 | name: '__session', // limitation because Firebase only supports that cookie name 24 | signed: false, // limitation because Firebase only supports __session cookie, no __session.sig. 25 | domain: isDevMode ? '.devtube.xxx' : '.dev.tube', 26 | secret: process.env.COOKIE_SECRET, 27 | })) 28 | app.use(passport.initialize()); 29 | app.use(passport.session()); 30 | 31 | app.set("port", port); 32 | app.use(cors({ 33 | credentials: true, 34 | origin: isDevMode ? true : 'https://dev.tube' 35 | })); 36 | app.use(body.json()); 37 | app.use(morgan('tiny')) 38 | 39 | 40 | console.time("Routes init") 41 | require("./src/routes/index")(app); 42 | console.timeEnd("Routes init") 43 | 44 | console.timeEnd("Cold start") 45 | 46 | if (isDevMode) { 47 | const listener = app.listen(port, () => { 48 | console.log("Your app is listening on port " + listener.address().port); 49 | }); 50 | } 51 | 52 | exports.api = app; 53 | -------------------------------------------------------------------------------- /express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-tube-express", 3 | "description": "Dev.Tube Web Express App", 4 | "version": "1.0.0", 5 | "author": "Eduards Sizovs ", 6 | "main": "index.js", 7 | "private": true, 8 | "scripts": { 9 | "dev": "export GOOGLE_APPLICATION_CREDENTIALS=../datastore_key.json && MODE=dev nodemon index.js", 10 | "devReindex": "export GOOGLE_APPLICATION_CREDENTIALS=../datastore_key.json && node reindex-run.js", 11 | "reindex": "node reindex-run.js", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "@google-cloud/datastore": "^6.6.2", 16 | "axios": "^0.18.0", 17 | "body-parser": "^1.18.3", 18 | "cookie-session": "^2.0.0", 19 | "cors": "^2.8.4", 20 | "dayjs": "^1.11.2", 21 | "dnscache": "^1.0.2", 22 | "dotenv": "^16.0.0", 23 | "express": "^4.16.4", 24 | "express-async-handler": "^1.2.0", 25 | "feed": "^4.2.2", 26 | "googleapis": "^100.0.0", 27 | "joi": "^17.6.0", 28 | "json-diff": "^0.7.4", 29 | "lodash": "^4.17.21", 30 | "memoizee": "^0.4.15", 31 | "morgan": "^1.10.0", 32 | "passport": "^0.5.2", 33 | "passport-github2": "^0.1.12", 34 | "passport-google-oauth2": "^0.2.0", 35 | "passport-twitter": "^1.0.4", 36 | "sitemap": "^7.1.1", 37 | "twit": "^2.2.11" 38 | }, 39 | "devDependencies": { 40 | "jest": "^28.1.0", 41 | "nodemon": "^2.0.15" 42 | }, 43 | "jest": { 44 | "testPathIgnorePatterns": [ 45 | "tmp" 46 | ], 47 | "testMatch": [ 48 | "**/**.test.js" 49 | ] 50 | }, 51 | "volta": { 52 | "node": "18.20.8" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /vue/src/VideoComments.vue: -------------------------------------------------------------------------------- 1 | 8 | 54 | -------------------------------------------------------------------------------- /vue/src/Login.vue: -------------------------------------------------------------------------------- 1 | 29 | 39 | 50 | -------------------------------------------------------------------------------- /vue/src/user/index.js: -------------------------------------------------------------------------------- 1 | import { api } from "../api"; 2 | 3 | const state = { 4 | favorites: [], 5 | watched: [], 6 | later: [], 7 | subscribedToWeekly: false 8 | }; 9 | 10 | const actions = { 11 | bootstrap({ commit }) { 12 | api.get("/user/bootstrap").then(({ data }) => commit("bootstrap", data)); 13 | }, 14 | async add({ commit }, { list, videoID }) { 15 | return api 16 | .post("/user/lists/" + list + "/" + videoID) 17 | .then(({ data }) => commit("updated", { list, data })); 18 | }, 19 | async remove({ commit }, { list, videoID }) { 20 | return api 21 | .delete("/user/lists/" + list + "/" + videoID) 22 | .then(({ data }) => commit("updated", { list, data })); 23 | }, 24 | async subscribeWeekly({ commit }) { 25 | return api 26 | .post("/user/weekly-subscription") 27 | .then(() => commit("subscribedWeekly", true)); 28 | }, 29 | async unsubscribeWeekly({ commit }) { 30 | return api 31 | .delete("/user/weekly-subscription") 32 | .then(() => commit("subscribedWeekly", false)); 33 | } 34 | }; 35 | 36 | const mutations = { 37 | subscribedWeekly: (state, yesNo) => { 38 | state.subscribedToWeekly = yesNo; 39 | }, 40 | updated: (state, { list, data }) => { 41 | state[list] = [...data]; 42 | }, 43 | bootstrap: ( 44 | state, 45 | { favorites = [], watched = [], later = [], subscribedToWeekly = false } 46 | ) => { 47 | state.favorites = favorites; 48 | state.watched = watched; 49 | state.later = later; 50 | state.subscribedToWeekly = subscribedToWeekly; 51 | } 52 | }; 53 | 54 | export default { 55 | namespaced: true, 56 | state, 57 | actions, 58 | mutations 59 | }; 60 | -------------------------------------------------------------------------------- /vue/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | The best tech talks for developers 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-tube-web", 3 | "description": "Dev.Tube Web", 4 | "version": "1.0.0", 5 | "author": "Eduards Sizovs ", 6 | "private": true, 7 | "scripts": { 8 | "lint": "vue-cli-service lint", 9 | "dev": "vue-cli-service serve", 10 | "build": "vue-cli-service build --report" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^6.1.1", 14 | "@fortawesome/free-brands-svg-icons": "^6.1.1", 15 | "@fortawesome/free-regular-svg-icons": "^6.1.1", 16 | "@fortawesome/free-solid-svg-icons": "^6.1.1", 17 | "@fortawesome/vue-fontawesome": "^2.0.6", 18 | "axios": "^0.19.0", 19 | "bulma": "^0.9.4", 20 | "dayjs": "^1.5.16", 21 | "parse-url": "^5.0.7", 22 | "plausible-tracker": "^0.3.9", 23 | "vue": "^2.7.14", 24 | "vue-head": "^2.2.0", 25 | "vue-loading-overlay": "^3.0.1", 26 | "vue-notification": "^1.3.12", 27 | "vue-observe-visibility": "^1.0.0", 28 | "vue-router": "^3.0.1", 29 | "vuex": "^3.0.1" 30 | }, 31 | "devDependencies": { 32 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5", 33 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 34 | "@vue/cli-plugin-babel": "^4.1.0", 35 | "@vue/cli-plugin-eslint": "^4.1.0", 36 | "@vue/cli-plugin-router": "^4.1.0", 37 | "@vue/cli-plugin-vuex": "^4.1.0", 38 | "@vue/cli-service": "^4.1.0", 39 | "@vue/eslint-config-prettier": "^5.0.0", 40 | "babel-eslint": "^10.0.3", 41 | "eslint": "^5.16.0", 42 | "eslint-plugin-prettier": "^3.1.1", 43 | "eslint-plugin-vue": "^5.0.0", 44 | "sass": "^1.86.0", 45 | "prettier": "^1.19.1", 46 | "pug": "^2.0.4", 47 | "pug-plain-loader": "^1.0.0", 48 | "sass-loader": "^10.2.1", 49 | "vue-template-compiler": "^2.7.14" 50 | }, 51 | "volta": { 52 | "node": "16.20.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /vue/src/auth/index.js: -------------------------------------------------------------------------------- 1 | import { apiUrl, api } from "../api"; 2 | 3 | const state = { 4 | popupVisible: false, 5 | loggedIn: false, 6 | youtubeAccess: false, 7 | admin: false, 8 | avatar: undefined, 9 | username: undefined, 10 | karma: 0 11 | }; 12 | 13 | const getters = { 14 | isLoggedIn: state => state.loggedIn, 15 | isAdmin: state => state.admin, 16 | isPopupVisible: state => state.popupVisible, 17 | hasYoutubeAccess: state => state.youtubeAccess 18 | }; 19 | 20 | const actions = { 21 | bootstrap({ commit }) { 22 | api.get("/auth/loggedIn").then(({ data }) => commit("loggedIn", data)); 23 | }, 24 | showPopup({ commit }) { 25 | commit("popupVisible", true); 26 | const classes = document.documentElement.classList; 27 | classes?.add("is-clipped"); 28 | }, 29 | hidePopup({ commit }) { 30 | commit("popupVisible", false); 31 | const classes = document.documentElement.classList; 32 | classes?.remove("is-clipped"); 33 | }, 34 | // eslint-disable-next-line no-unused-vars 35 | login({ commit }, provider) { 36 | window.location.href = 37 | apiUrl + "auth/" + provider + "?returnTo=" + window.location.href; 38 | }, 39 | logout() { 40 | window.location.href = 41 | apiUrl + "auth/logout" + "?returnTo=" + window.location.href; 42 | } 43 | }; 44 | 45 | const mutations = { 46 | loggedIn: ( 47 | state, 48 | { loggedIn, admin, avatar, username, karma, youtubeAccess } 49 | ) => { 50 | state.loggedIn = loggedIn; 51 | state.username = username; 52 | state.avatar = avatar; 53 | state.admin = admin; 54 | state.karma = karma; 55 | state.youtubeAccess = youtubeAccess; 56 | }, 57 | popupVisible: (state, visible) => { 58 | state.popupVisible = visible; 59 | } 60 | }; 61 | 62 | export default { 63 | namespaced: true, 64 | state, 65 | getters, 66 | actions, 67 | mutations 68 | }; 69 | -------------------------------------------------------------------------------- /vue/src/VideoComment.vue: -------------------------------------------------------------------------------- 1 | 21 | 26 | 57 | -------------------------------------------------------------------------------- /vue/src/EditVideo.vue: -------------------------------------------------------------------------------- 1 | 23 | 26 | 34 | 80 | -------------------------------------------------------------------------------- /express/src/routes/tweets.js: -------------------------------------------------------------------------------- 1 | const Twit = require("twit"); 2 | const { adminsOnly } = require("../libs/Middlewares"); 3 | const { Youtube } = require("../libs/Youtube"); 4 | const asyncHandler = require('express-async-handler') 5 | const router = require("express").Router(); 6 | 7 | const config = { 8 | consumer_key: process.env.AUTO_TWT_CONSUMER_KEY || "none", 9 | consumer_secret: process.env.AUTO_TWT_CONSUMER_SECRET || "none", 10 | access_token: process.env.AUTO_TWT_ACCESS_TOKEN || "none", 11 | access_token_secret: process.env.AUTO_TWT_ACCESS_SECRET || "none", 12 | }; 13 | const twitter = new Twit(config); 14 | 15 | function random(min, max) { 16 | return Math.floor(Math.random() * (max - min + 1)) + min; 17 | } 18 | 19 | router.post("/", adminsOnly, asyncHandler(async (req, res) => { 20 | const video = req.body 21 | const watchingNow = random(5, 50); 22 | const comments = await new Youtube(process.env.YOUTUBE_API_KEY).fetchCommentCount(video.objectID); 23 | await tweetTrending(video, watchingNow, comments); 24 | res.sendStatus(200); 25 | })) 26 | 27 | function kilo(num) { 28 | return num < 1000 ? num : Math.floor(num / 1000) + "K" 29 | } 30 | 31 | async function tweetTrending(video, watchingNow, comments) { 32 | if (!video?.objectID) { 33 | throw new Error("No video to tweet"); 34 | } 35 | if (!watchingNow) { 36 | throw new Error("Zero or no people watching.") 37 | } 38 | if (!comments) { 39 | throw new Error("Zero or no people commented.") 40 | } 41 | 42 | 43 | const status = [ 44 | ...[ 45 | `@${video.speakerTwitters[0]}:`, 46 | `📈 Your talk "${video.title}" is trending on DevTube:`, 47 | `❤️ ${kilo(video.likes)} likes`, 48 | `✍️ ${kilo(comments)} comments `, 49 | `📺 ${watchingNow} watching now`, 50 | "", 51 | `https://dev.tube/video/${video.objectID}`, 52 | ], 53 | ] 54 | .filter((line) => line !== undefined) 55 | .join("\n"); 56 | 57 | return twitter 58 | .post("statuses/update", { status }) 59 | .catch((e) => 60 | console.error(new Error(`Tweeting of ${video.objectID} failed: ${e}`)) 61 | ); 62 | }; 63 | 64 | module.exports = router; 65 | module.exports.tweetTrending = tweetTrending -------------------------------------------------------------------------------- /vue/src/VideoCard.vue: -------------------------------------------------------------------------------- 1 | 25 | 46 | 66 | -------------------------------------------------------------------------------- /express/src/routes/videos.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require('express-async-handler') 2 | const router = require("express").Router(); 3 | const { authenticated, adminsOnly } = require("../libs/Middlewares"); 4 | const { updateVideo, createVideo, oneVideo, replaceVideo, deleteVideo } = require("../libs/Datastore") 5 | const memoize = require("memoizee"); 6 | const Joi = require("joi") 7 | 8 | const videoSchema = memoize(() => { 9 | const schema = Joi.object().keys({ 10 | objectID: Joi.string().required(), 11 | title: Joi.string().required(), 12 | channelId: Joi.string().required(), 13 | channelTitle: Joi.string().required(), 14 | contributor: Joi.string().required(), 15 | likes: Joi.number().required(), 16 | duration: Joi.string().isoDuration().required(), 17 | recordingDate: Joi.date().iso().required(), 18 | submissionDate: Joi.date().iso().required(), 19 | status: Joi.string().valid("approved", "submitted").required(), 20 | speakerNames: Joi.array().min(1).required(), 21 | speakerTwitters: Joi.array().min(1).required(), 22 | topics: Joi.array().min(1).required(), 23 | series: Joi.array() 24 | }) 25 | return schema; 26 | }) 27 | 28 | router.get("/:videoId", asyncHandler(async (req, res) => { 29 | const { videoId } = req.params; 30 | const video = await oneVideo(videoId); 31 | if (!video) { 32 | res.sendStatus(404) 33 | } 34 | res.json(video); 35 | })); 36 | 37 | router.delete("/:videoId", adminsOnly, asyncHandler(async (req, res) => { 38 | const videoId = req.params.videoId; 39 | await deleteVideo(videoId); 40 | res.sendStatus(200); 41 | })) 42 | 43 | router.post("/:videoId/like", authenticated, asyncHandler(async (req, res) => { 44 | const videoId = req.params.videoId; 45 | await updateVideo(videoId, (video) => { 46 | video.likes++ 47 | res.send("" + video.likes) 48 | }) 49 | 50 | })) 51 | 52 | router.post("/", authenticated, asyncHandler(async (req, res) => { 53 | const status = req.user.admin ? "approved" : "submitted" 54 | const video = Joi.attempt({ ...req.body, status }, videoSchema()); 55 | await createVideo(video); 56 | res.sendStatus(200) 57 | })) 58 | 59 | router.put("/", adminsOnly, asyncHandler(async (req, res) => { 60 | const video = Joi.attempt(req.body, videoSchema()); 61 | await replaceVideo(video); 62 | res.sendStatus(200); 63 | })) 64 | 65 | module.exports = router; 66 | -------------------------------------------------------------------------------- /express/src/routes/youtube.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require('express-async-handler') 2 | const router = require("express").Router(); 3 | const { authenticated } = require("../libs/Middlewares") 4 | const { oneVideo } = require('../libs/Datastore'); 5 | const { Youtube } = require("../libs/Youtube") 6 | 7 | function userAuth(req) { 8 | const { google } = require('googleapis'); 9 | const oauthClient = new google.auth.OAuth2(process.env.GOOG_CLIENT_ID, process.env.GOOG_CLIENT_SECRET, ""); 10 | oauthClient.setCredentials(req.user.google) 11 | return oauthClient; 12 | } 13 | 14 | router.get("/:commentId/replies", asyncHandler(async (req, res) => { 15 | const { commentId } = req.params; 16 | const replies = await new Youtube(process.env.YOUTUBE_API_KEY).fetchReplies(commentId); 17 | res.json(replies); 18 | })); 19 | 20 | router.post("/:commentId/replies", authenticated, asyncHandler(async (req, res) => { 21 | const { commentId } = req.params; 22 | const { text } = req.body; 23 | const newReply = await new Youtube(userAuth(req)).replyToComment(commentId, text); 24 | res.json(newReply); 25 | })); 26 | 27 | router.get("/:videoId/comments", asyncHandler(async (req, res) => { 28 | const { videoId } = req.params; 29 | const pageToken = req.query.nextPageToken; 30 | try { 31 | const comments = await new Youtube(process.env.YOUTUBE_API_KEY).fetchComments(videoId, pageToken); 32 | res.json(comments); 33 | } catch (e) { 34 | const [error] = e.errors 35 | if (error.reason === 'commentsDisabled') { 36 | res.json({ comments: [], commentsDisabled: true }); 37 | } else { 38 | throw e; 39 | } 40 | } 41 | 42 | })); 43 | 44 | router.post("/:videoId/comments", authenticated, asyncHandler(async (req, res) => { 45 | const { videoId } = req.params; 46 | const { text } = req.body; 47 | const newComment = await new Youtube(userAuth(req)).addComment(videoId, text); 48 | res.json(newComment) 49 | })); 50 | 51 | 52 | router.get("/:videoId", authenticated, asyncHandler(async (req, res) => { 53 | const { videoId } = req.params; 54 | 55 | if (await oneVideo(videoId)) { 56 | res.send({ error: "Video already added." }); 57 | return; 58 | } 59 | 60 | const { video, error } = await new Youtube(process.env.YOUTUBE_API_KEY).fetchVideo(videoId); 61 | if (error) { 62 | res.send({ error }); 63 | return 64 | } 65 | 66 | res.send({ ...video, contributor: req.user.username }); 67 | 68 | })); 69 | 70 | module.exports = router; 71 | -------------------------------------------------------------------------------- /vue/src/VideoCommentEditor.vue: -------------------------------------------------------------------------------- 1 | 27 | 41 | 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/watch-devtube/web/actions/workflows/server-deploy.yml/badge.svg)](https://github.com/watch-devtube/web/) 2 | 3 | # DevTube 4 | 5 | This repository contains `Vue.js` frontend and `Express.js` backend for DevTube. 6 | 7 | # Why DevTube? 8 | 9 | DevTube contains the best `350+` tech talks from different sources – @eduardsi's personal favorites, github lists, YouTube most liked. You can say that DevTube is YouTube uncluttered + some secret gems reuploaded from InfoQ, Vimeo, and private video archives. The talks are updated and contributed regularly thanks to the community. 10 | 11 | **💎 Discover hidden gems** – Watch videos that are not publicly available on YouTube (e.g. [Chad Fowler's "Tiny"](https://dev.tube/video/NXSS01n97G0)). 12 | 13 | **🕛 Save time** – Watch only the best talks, curated by the community, grouped by categories and speakers. Quality beats quantity. 14 | 15 | **🔖 Create lists** – Watch later, bookmark, and keep track of watched videos. 16 | 17 | **💬 Discuss** – Read, write, and reply to comments directly from DevTube. 18 | 19 | **❤️ Contribute** – Get karma for video contributions. Your name will also be visible next to the video. 20 | 21 | **🔔 Subscribe** – Stay up-to-date with the latest videos via RSS. Too busy? Receive one tech talk per week. 22 | 23 | **🧘 Fewer distractions** – No annoying YouTube algorithms, irrelevant videos, ads, and tracking. 24 | 25 | # How to run DevTube locally 26 | 27 | ### Get datastore access 28 | 29 | Ask the repo owners for Google Datastore credentials, then put them in `./datastore_key.json`. 30 | 31 | ### Configure env variables 32 | 33 | Create a file `./express/.env` with the following variables: 34 | 35 | ``` 36 | COOKIE_SECRET = DEVDEVDEVDEVDEVDEVDEVDEVDEVDEVDEV 37 | DEVTUBE_HOST = http://devtube.xxx:8080 38 | 39 | YOUTUBE_API_KEY = 40 | 41 | TWITTER_CONSUMER_KEY = 42 | TWITTER_CONSUMER_SECRET = 43 | 44 | GH_CLIENT_ID = 45 | GH_CLIENT_SECRET = 46 | 47 | GOOG_CLIENT_ID = 48 | GOOG_CLIENT_SECRET = 49 | ``` 50 | 51 | ### Add new entries to /etc/hosts file 52 | 53 | ``` 54 | 127.0.0.1 devtube.xxx 55 | ``` 56 | 57 | #### Run backend 58 | 59 | ```bash 60 | # From ./express directory run: 61 | npm install 62 | npm run dev 63 | ``` 64 | 65 | #### Run frontend 66 | 67 | ```bash 68 | # from ./vue directory run: 69 | npm install 70 | npm run dev 71 | ``` 72 | 73 | > 🚀 DevTube front-end is now running on [devtube.xxx:8080](http://devtube.xxx:8080) 74 | -------------------------------------------------------------------------------- /vue/src/NavBar.vue: -------------------------------------------------------------------------------- 1 | 32 | 53 | 77 | -------------------------------------------------------------------------------- /vue/src/Banner.vue: -------------------------------------------------------------------------------- 1 | 20 | 123 | -------------------------------------------------------------------------------- /.github/workflows/server-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | # Run every 4 hours 9 | - cron: "0 */4 * * *" 10 | 11 | jobs: 12 | worker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Publish 17 | uses: cloudflare/wrangler-action@v3 18 | with: 19 | apiToken: ${{ secrets.CF_API_TOKEN }} 20 | workingDirectory: "worker" 21 | 22 | web: 23 | runs-on: ubuntu-latest 24 | defaults: 25 | run: 26 | working-directory: ./vue 27 | steps: 28 | - name: checkout 29 | uses: actions/checkout@v3 30 | - name: install node 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: "16" 34 | - name: build 35 | run: npm install && npm run build 36 | - name: deploy 37 | uses: w9jds/firebase-action@master 38 | with: 39 | args: deploy --only hosting 40 | env: 41 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 42 | PROJECT_PATH: ./vue 43 | server: 44 | permissions: 45 | contents: "read" 46 | id-token: "write" 47 | runs-on: ubuntu-latest 48 | defaults: 49 | run: 50 | working-directory: ./express 51 | steps: 52 | - name: checkout 53 | uses: actions/checkout@v3 54 | 55 | - name: build 56 | uses: actions/setup-node@v3 57 | with: 58 | node-version: "14" 59 | 60 | - name: install node deps 61 | run: npm install --only=production 62 | 63 | - name: env file 64 | run: | 65 | touch .env 66 | echo COOKIE_SECRET=${{ secrets.COOKIE_SECRET }} >> .env 67 | echo TWITTER_CONSUMER_KEY=${{ secrets.TWITTER_CONSUMER_KEY }} >> .env 68 | echo TWITTER_CONSUMER_SECRET=${{ secrets.TWITTER_CONSUMER_SECRET }} >> .env 69 | 70 | echo AUTO_TWT_CONSUMER_KEY=${{ secrets.AUTO_TWT_CONSUMER_KEY }} >> .env 71 | echo AUTO_TWT_CONSUMER_SECRET=${{ secrets.AUTO_TWT_CONSUMER_SECRET }} >> .env 72 | echo AUTO_TWT_ACCESS_TOKEN=${{ secrets.AUTO_TWT_ACCESS_TOKEN }} >> .env 73 | echo AUTO_TWT_ACCESS_SECRET=${{ secrets.AUTO_TWT_ACCESS_SECRET }} >> .env 74 | 75 | echo GH_CLIENT_ID=${{ secrets.GH_CLIENT_ID }} >> .env 76 | echo GH_CLIENT_SECRET=${{ secrets.GH_CLIENT_SECRET }} >> .env 77 | echo GOOG_CLIENT_ID=${{ secrets.GOOG_CLIENT_ID }} >> .env 78 | echo GOOG_CLIENT_SECRET=${{ secrets.GOOG_CLIENT_SECRET }} >> .env 79 | echo YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }} >> .env 80 | cat .env 81 | 82 | - name: gcp auth 83 | uses: "google-github-actions/auth@v2" 84 | with: 85 | credentials_json: "${{ secrets.GCP_CREDENTIALS }}" 86 | 87 | - name: gcloud cli install 88 | uses: "google-github-actions/setup-gcloud@v2" 89 | 90 | - name: prepare data dir 91 | run: mkdir ./data 92 | 93 | - name: build index 94 | run: npm run reindex 95 | 96 | - name: deploy 97 | run: "gcloud functions deploy api --trigger-http --runtime nodejs18 --memory=1024MB --timeout=1m --project=dev-tube --no-gen2" 98 | -------------------------------------------------------------------------------- /express/src/routes/user.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | 3 | const { authenticated } = require("../libs/Middlewares"); 4 | const { datastoreForever } = require("../libs/Datastore") 5 | 6 | router.get("/bootstrap", async (req, res) => { 7 | 8 | if (!req.user) { 9 | res.json({}) 10 | return; 11 | } 12 | 13 | const userKey = req.user.email 14 | try { 15 | const [user] = await datastoreForever().get(datastoreForever().key(['user', userKey])); 16 | res.json(user) 17 | } catch (err) { 18 | console.log(err); 19 | res.sendStatus(500) 20 | } 21 | }) 22 | 23 | router.post("/weekly-subscription", authenticated, async (req, res) => { 24 | const userKey = req.user.email 25 | try { 26 | const key = datastoreForever().key(['user', userKey]); 27 | const tx = datastoreForever().transaction(); 28 | 29 | const [user] = await tx.get(key); 30 | const data = user || { 31 | later: [], 32 | favorites: [], 33 | watched: [], 34 | } 35 | 36 | data.subscribedToWeekly = true; 37 | 38 | tx.upsert({ key, data }); 39 | await tx.commit(); 40 | 41 | res.json(data) 42 | } catch (e) { 43 | console.error(e); 44 | res.sendStatus(500); 45 | } 46 | }) 47 | 48 | router.delete("/weekly-subscription", authenticated, async (req, res) => { 49 | const userKey = req.user.email 50 | try { 51 | const key = datastoreForever().key(['user', userKey]); 52 | const tx = datastoreForever().transaction(); 53 | const [user] = await tx.get(key); 54 | user.subscribedToWeekly = false 55 | tx.upsert({ key, data: user }); 56 | await tx.commit(); 57 | res.json(user) 58 | } catch (e) { 59 | console.error(e); 60 | res.sendStatus(500); 61 | } 62 | }) 63 | 64 | router.delete("/lists/:list/:videoId", authenticated, async (req, res) => { 65 | const userKey = req.user.email 66 | const { list, videoId } = req.params 67 | if (!list || !videoId || !userKey) { 68 | res.sendStatus(500) 69 | return; 70 | } 71 | 72 | const key = datastoreForever().key(['user', userKey]) 73 | 74 | try { 75 | const tx = datastoreForever().transaction(); 76 | const [data] = await tx.get(key); 77 | 78 | 79 | const index = data[list].indexOf(videoId); 80 | if (index != -1) { 81 | data[list].splice(index, 1); 82 | tx.upsert({ key, data }); 83 | await tx.commit(); 84 | } 85 | 86 | res.json(data[list]) 87 | } catch { 88 | res.sendStatus(500); 89 | } 90 | 91 | }) 92 | 93 | router.post("/lists/:list/:videoId", authenticated, async (req, res) => { 94 | const { list, videoId } = req.params 95 | if (!list || !videoId) { 96 | res.sendStatus(500) 97 | return; 98 | } 99 | 100 | const userKey = req.user.email 101 | 102 | try { 103 | const key = datastoreForever().key(['user', userKey]); 104 | const tx = datastoreForever().transaction(); 105 | 106 | const [user] = await tx.get(key); 107 | const data = user || { 108 | later: [], 109 | favorites: [], 110 | watched: [] 111 | } 112 | 113 | if (!data[list].includes(videoId)) { 114 | data[list].push(videoId); 115 | tx.upsert({ key, data }); 116 | await tx.commit(); 117 | } 118 | 119 | res.json(data[list]) 120 | } catch (e) { 121 | console.error(e); 122 | res.sendStatus(500); 123 | } 124 | }); 125 | 126 | module.exports = router; 127 | -------------------------------------------------------------------------------- /express/src/libs/Datastore.js: -------------------------------------------------------------------------------- 1 | const jsonDiff = require("json-diff"); 2 | const memoize = require("memoizee"); 3 | const { readFileSync } = require("fs"); 4 | 5 | const datastoreForever = memoize(() => { 6 | const { Datastore } = require("@google-cloud/datastore"); 7 | const datastore = new Datastore(); 8 | return datastore; 9 | }) 10 | 11 | const processVideos = (mapper = (data) => data, done = () => { }) => { 12 | let changed = 0; 13 | let processed = 0; 14 | datastoreForever() 15 | .runQueryStream(datastoreForever().createQuery("videos").filter("status", "approved")) 16 | .on("error", console.error) 17 | .on("data", (data) => { 18 | const key = data[datastoreForever().KEY]; 19 | const copy = { ...data }; 20 | mapper(copy); 21 | const diff = jsonDiff.diffString(data, copy); 22 | if (diff) { 23 | datastoreForever().update({ key, data: copy }); 24 | changed++; 25 | console.log(diff); 26 | } 27 | processed++ 28 | }) 29 | .on("end", () => { 30 | console.log(`Processing has completed. ${processed} entities processed. ${changed} updated.`) 31 | done() 32 | } 33 | ); 34 | }; 35 | 36 | const oneVideo = async (videoID) => { 37 | const key = datastoreForever().key(["videos", videoID]) 38 | const [video] = await datastoreForever().get(key); 39 | return video; 40 | } 41 | 42 | const deleteVideo = async (videoID) => { 43 | const key = datastoreForever().key(["videos", videoID]) 44 | return datastoreForever().delete(key); 45 | } 46 | 47 | const updateVideo = async (videoID, mutate) => { 48 | const key = datastoreForever().key(["videos", videoID]) 49 | const [video] = await datastoreForever().get(key); 50 | 51 | const newVideo = { ...video } 52 | mutate(newVideo); 53 | const diff = jsonDiff.diffString(video, newVideo); 54 | console.log(diff) 55 | return datastoreForever().update({ key, data: newVideo }) 56 | } 57 | 58 | 59 | 60 | const replaceVideo = async (newVideo) => { 61 | const key = datastoreForever().key(["videos", newVideo.objectID]) 62 | const [video] = await datastoreForever().get(key); 63 | const diff = jsonDiff.diffString(video, newVideo); 64 | console.log(diff) 65 | return datastoreForever().update({ key, data: newVideo }) 66 | } 67 | 68 | const createVideo = async (newVideo) => { 69 | const key = datastoreForever().key(["videos", newVideo.objectID]) 70 | return datastoreForever().insert({ key, data: newVideo }) 71 | } 72 | 73 | const searchApprovedVideos = () => { 74 | const q = datastoreForever().createQuery("videos").filter("status", "approved"); 75 | return datastoreForever().runQuery(q); 76 | } 77 | 78 | const searchAllVideos = () => { 79 | const q = datastoreForever().createQuery("videos"); 80 | return datastoreForever().runQuery(q).then(([videos]) => videos); 81 | } 82 | 83 | const readApprovedVideos = () => JSON.parse(readFileSync('./data/videos.json', { encoding: "utf8" })); 84 | const searchApprovedVideosForever = memoize(readApprovedVideos) 85 | 86 | module.exports.processVideos = processVideos; 87 | module.exports.oneVideo = oneVideo; 88 | module.exports.updateVideo = updateVideo; 89 | module.exports.createVideo = createVideo; 90 | module.exports.replaceVideo = replaceVideo; 91 | module.exports.deleteVideo = deleteVideo; 92 | module.exports.searchApprovedVideos = searchApprovedVideos; 93 | module.exports.searchAllVideos = searchAllVideos; 94 | module.exports.searchApprovedVideosForever = searchApprovedVideosForever; 95 | module.exports.datastoreForever = datastoreForever; -------------------------------------------------------------------------------- /vue/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | 4 | import VueHead from "vue-head"; 5 | import VueRouter from "vue-router"; 6 | import Notifications from "vue-notification"; 7 | import VueObserveVisibility from "vue-observe-visibility"; 8 | 9 | import { library } from "@fortawesome/fontawesome-svg-core"; 10 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 11 | 12 | import { 13 | faTwitter, 14 | faGithub, 15 | faGoogle, 16 | faLinkedin 17 | } from "@fortawesome/free-brands-svg-icons"; 18 | import { 19 | faTimes, 20 | faCheck, 21 | faEnvelope, 22 | faPlus, 23 | faCircle, 24 | faHashtag, 25 | faSquareRss 26 | } from "@fortawesome/free-solid-svg-icons"; 27 | import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons/faStar"; 28 | import { faCheckCircle as fasCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; 29 | import { faClock as fasClock } from "@fortawesome/free-solid-svg-icons/faClock"; 30 | import { faHeart as fasHeart } from "@fortawesome/free-solid-svg-icons/faHeart"; 31 | import { 32 | faHeart, 33 | faEye, 34 | faEyeSlash, 35 | faStar, 36 | faCheckCircle, 37 | faClock, 38 | faEdit 39 | } from "@fortawesome/free-regular-svg-icons"; 40 | library.add( 41 | faTimes, 42 | faCheck, 43 | faClock, 44 | faStar, 45 | faEnvelope, 46 | faPlus, 47 | faGithub, 48 | faCircle, 49 | faGoogle, 50 | faEdit, 51 | faEyeSlash, 52 | faHashtag, 53 | faHeart, 54 | faSquareRss, 55 | faTwitter, 56 | faLinkedin, 57 | faEye, 58 | faCheckCircle, 59 | fasHeart, 60 | fasCheckCircle, 61 | fasStar, 62 | fasClock, 63 | faClock 64 | ); 65 | 66 | import VideoComment from "./VideoComment.vue"; 67 | Vue.component("VideoComment", VideoComment); 68 | Vue.component("font-awesome-icon", FontAwesomeIcon); 69 | 70 | import App from "./App.vue"; 71 | import Watch from "./Watch.vue"; 72 | import Search from "./Search.vue"; 73 | import { ago, published, year, durationFull } from "./helpers/filters"; 74 | 75 | import auth from "./auth"; 76 | import edit from "./edit"; 77 | import user from "./user"; 78 | 79 | Vue.use(VueHead, { 80 | separator: "", 81 | complement: "" 82 | }); 83 | Vue.use(Vuex); 84 | Vue.use(Notifications); 85 | Vue.use(VueRouter); 86 | Vue.use(VueObserveVisibility); 87 | 88 | Vue.filter("durationFull", durationFull); 89 | Vue.filter("published", published); 90 | Vue.filter("year", year); 91 | Vue.filter("ago", ago); 92 | require("./styles/main.scss"); 93 | 94 | const router = new VueRouter({ 95 | mode: "history", 96 | linkExactActiveClass: "is-active", 97 | routes: [ 98 | { name: "video", path: "/video/:id", component: Watch, props: true }, 99 | { 100 | name: "search", 101 | path: "/:query?", 102 | component: Search 103 | } 104 | ], 105 | scrollBehavior() { 106 | return { x: 0, y: 0 }; 107 | } 108 | }); 109 | 110 | const store = new Vuex.Store({ 111 | modules: { 112 | auth, 113 | edit, 114 | user 115 | }, 116 | strict: true 117 | }); 118 | 119 | import Plausible from 'plausible-tracker' 120 | const { enableAutoPageviews } = Plausible({ 121 | domain: 'dev.tube', 122 | apiHost: 'https://a.devternity.com' 123 | }) 124 | enableAutoPageviews() 125 | 126 | new Vue({ 127 | el: "#app", 128 | store, 129 | router, 130 | render: h => h(App), 131 | async created() { 132 | store.dispatch("auth/bootstrap"); 133 | store.dispatch("user/bootstrap"); 134 | } 135 | }); 136 | -------------------------------------------------------------------------------- /express/src/routes/search.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require('express-async-handler') 2 | const { datastoreForever, searchApprovedVideosForever, searchAllVideos } = require("../libs/Datastore"); 3 | const { authenticated } = require('../libs/Middlewares'); 4 | const router = require("express").Router(); 5 | const _ = require("lodash"); 6 | 7 | const NOTHING_FOUND = `¯\\_(ツ)_/¯ No talks matching your criteria` 8 | 9 | router.post("/", asyncHandler(async (req, res) => { 10 | const { p, s } = req.body; 11 | 12 | const search = req.user?.admin ? searchAllVideos : searchApprovedVideosForever 13 | const foundItems = await search(); 14 | const { videos, more } = await paginate(foundItems, p, s); 15 | 16 | res.json({ 17 | videos, 18 | more 19 | }) 20 | })); 21 | 22 | router.post("/@:speaker", asyncHandler(async (req, res) => { 23 | const { speaker } = req.params; 24 | const { p, s } = req.body; 25 | 26 | const foundItems = await searchApprovedVideosForever() 27 | const someItems = foundItems.filter(it => it.speakerTwitters.includes(speaker)) 28 | 29 | const { videos, more } = await paginate(someItems, p, s); 30 | 31 | const speakerIndex = videos[0]?.speakerTwitters.indexOf(speaker) 32 | const whois = videos[0]?.speakerNames[speakerIndex]; 33 | res.json({ 34 | videos, 35 | more, 36 | speakerIndex, 37 | title: videos.length ? `The best tech talks by ${whois}` : NOTHING_FOUND 38 | }) 39 | })); 40 | 41 | router.post("/~:topic(*)", asyncHandler(async (req, res) => { 42 | const { topic } = req.params; 43 | const { p, s } = req.body; 44 | 45 | const foundItems = await searchApprovedVideosForever() 46 | const someItems = foundItems.filter(it => it.topics.includes(topic)) 47 | 48 | const { videos, more } = await paginate(someItems, p, s); 49 | res.json({ 50 | videos, 51 | more, 52 | title: videos.length ? `The best ${topic} talks` : NOTHING_FOUND 53 | }) 54 | })); 55 | 56 | router.post("/:list(later|watched|favorites)", authenticated, asyncHandler(async (req, res) => { 57 | const list = req.params.list; 58 | const tx = datastoreForever().transaction(); 59 | const [lists] = await tx.get(datastoreForever().key(['user', req.user.email])); 60 | if (!lists || !lists[list] || !lists[list].length) { 61 | res.json({}) 62 | return 63 | } 64 | const videoIds = lists[list]; 65 | 66 | const keys = videoIds.map(id => datastoreForever().key(["videos", id])); 67 | const [matches] = await tx.get(keys); 68 | matches.sort(match => -videoIds.indexOf(match.objectID)) 69 | const videos = matches; 70 | 71 | const titles = { 72 | later: `Tech talks to watch later (${matches.length})`, 73 | watched: `Tech talks I watched (${matches.length})`, 74 | favorites: `My favorite tech talks (${matches.length})` 75 | } 76 | 77 | res.json({ 78 | videos, 79 | title: titles[list] 80 | }) 81 | })); 82 | 83 | async function paginate(items, p, s) { 84 | const pg = Number(p) || 1; 85 | const pgSize = 30; 86 | const pgTotal = Math.ceil(items.length / pgSize); 87 | const offset = (pg - 1) * pgSize; 88 | const more = pgTotal === pg ? "" : pg + 1 89 | 90 | let sortedItems = items 91 | if (s === 'likes') { 92 | sortedItems = _.orderBy(items, ['likes'], ['desc']) 93 | } 94 | if (s === 'recent') { 95 | sortedItems = _.orderBy(items, ['submissionDate'], ['desc']) 96 | } 97 | 98 | const videos = _.drop(sortedItems, offset).slice(0, pgSize); 99 | return { 100 | videos, 101 | more 102 | } 103 | } 104 | 105 | module.exports = router; 106 | -------------------------------------------------------------------------------- /vue/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 25 | 35 | 36 | 38 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /vue/public/logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 25 | 35 | 36 | 38 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /express/src/routes/auth.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require('express-async-handler') 2 | const router = require("express").Router(); 3 | const passport = require("passport"); 4 | const TwitterStrategy = require('passport-twitter'); 5 | const GitHubStrategy = require('passport-github2'); 6 | const GoogleStrategy = require('passport-google-oauth2'); 7 | const { statsForever } = require('../libs/Stats'); 8 | 9 | const { returnBack } = require("../libs/Middlewares") 10 | 11 | const successRedirect = process.env.DEVTUBE_HOST || 'https://dev.tube' 12 | const successReturnToOrRedirect = successRedirect 13 | const admins = ['eduards@sizovs.net', 'eduards@devternity.com', 'eduards.sizovs@gmail.com'] 14 | 15 | function toUserProfile(request, access_token, refresh_token, profile, done) { 16 | const avatar = profile.photos[0].value 17 | const email = profile.emails[0].value 18 | if (!avatar || !email) { 19 | throw "Sorry, unable to retrieve user data from OAuth" 20 | } 21 | 22 | const admin = admins.includes(email); 23 | 24 | const user = request.user || { 25 | avatar, 26 | email, 27 | admin, 28 | username: profile.username || email.split("@")[0], 29 | provider: profile.provider 30 | }; 31 | 32 | user[profile.provider] = { access_token, refresh_token } 33 | 34 | return done(null, user); 35 | } 36 | 37 | passport.use(new GitHubStrategy({ 38 | clientID: process.env.GH_CLIENT_ID, 39 | clientSecret: process.env.GH_CLIENT_SECRET, 40 | callbackURL: "/api/auth/github/callback", 41 | passReqToCallback: true, 42 | scope: ['user:email'], 43 | proxy: true 44 | }, 45 | toUserProfile 46 | )); 47 | 48 | passport.use(new TwitterStrategy({ 49 | consumerKey: process.env.TWITTER_CONSUMER_KEY, 50 | consumerSecret: process.env.TWITTER_CONSUMER_SECRET, 51 | callbackURL: '/api/auth/twitter/callback', 52 | passReqToCallback: true, 53 | proxy: true, 54 | includeEmail: true 55 | }, 56 | toUserProfile)); 57 | 58 | passport.use(new GoogleStrategy({ 59 | clientID: process.env.GOOG_CLIENT_ID, 60 | clientSecret: process.env.GOOG_CLIENT_SECRET, 61 | callbackURL: "/api/auth/google/callback", 62 | passReqToCallback: true, 63 | proxy: true 64 | }, 65 | toUserProfile)); 66 | 67 | 68 | passport.serializeUser((user, cb) => { 69 | cb(null, user); 70 | }); 71 | 72 | passport.deserializeUser((obj, cb) => { 73 | cb(null, obj); 74 | }); 75 | 76 | router.get('/loggedIn', asyncHandler(async (req, res) => { 77 | const loggedIn = !!req.user; 78 | 79 | const username = req.user?.username; 80 | const avatar = req.user?.avatar; 81 | const admin = !!req.user?.admin; 82 | const youtubeAccess = !!req.user?.google 83 | 84 | const calculateKarma = async () => { 85 | if (loggedIn) { 86 | const stats = await statsForever(); 87 | return stats.karma[username] || 0 88 | } else { 89 | return 0; 90 | } 91 | } 92 | 93 | const karma = await calculateKarma(); 94 | 95 | res.json({ loggedIn, admin, avatar, username, karma, youtubeAccess }) 96 | })) 97 | 98 | router.get('/logout', (req, res) => { 99 | req.logout(); 100 | req.session = null; 101 | res.redirect(req.query.returnTo); 102 | }); 103 | 104 | const googleScopes = ['email', 'profile', 'https://www.googleapis.com/auth/youtube.force-ssl'] 105 | 106 | router.get('/github', returnBack, passport.authenticate('github')); 107 | router.get('/twitter', returnBack, passport.authenticate('twitter')); 108 | router.get('/google', returnBack, passport.authenticate('google', { 109 | scope: googleScopes, accessType: 'offline', prompt: 'consent' 110 | })); 111 | 112 | router.get('/google/callback', 113 | passport.authenticate('google', { successReturnToOrRedirect, failureRedirect: '/' })); 114 | 115 | router.get('/twitter/callback', 116 | passport.authenticate('twitter', { successReturnToOrRedirect, failureRedirect: '/' })); 117 | 118 | router.get('/github/callback', 119 | passport.authenticate('github', { successReturnToOrRedirect, failureRedirect: '/' })); 120 | 121 | module.exports = router; 122 | -------------------------------------------------------------------------------- /express/src/libs/Youtube.js: -------------------------------------------------------------------------------- 1 | const dayjs = require("dayjs"); 2 | const memoize = require("memoizee"); 3 | 4 | const googleForever = memoize(() => { 5 | const { google } = require('googleapis') 6 | return google; 7 | }) 8 | 9 | 10 | function mapComment(it) { 11 | const { totalReplyCount, canReply } = it.snippet; 12 | const { authorDisplayName, authorProfileImageUrl, textOriginal, publishedAt, likeCount } = it.snippet.topLevelComment.snippet 13 | const comment = { 14 | id: it.id, 15 | authorDisplayName, 16 | authorProfileImageUrl, 17 | textOriginal, 18 | totalReplyCount, 19 | likeCount, 20 | publishedAt, 21 | canReply 22 | } 23 | return comment; 24 | } 25 | 26 | function mapCommentReply(c) { 27 | const { authorDisplayName, authorProfileImageUrl, textOriginal, publishedAt, likeCount } = c.snippet; 28 | const reply = { 29 | id: c.id, 30 | authorDisplayName, 31 | authorProfileImageUrl, 32 | textOriginal, 33 | publishedAt, 34 | likeCount, 35 | } 36 | return reply; 37 | } 38 | 39 | class Youtube { 40 | constructor(auth) { 41 | this.auth = auth; 42 | this.youtube = googleForever().youtube({ 43 | version: 'v3', 44 | auth 45 | }) 46 | } 47 | 48 | async fetchCommentCount(videoId) { 49 | const response = await this.youtube.videos.list({ 50 | id: videoId, 51 | part: "statistics" 52 | }) 53 | 54 | return response?.data?.items?.[0]?.statistics?.commentCount || 0 55 | } 56 | 57 | async fetchVideo(videoId) { 58 | const response = await this.youtube.videos.list({ 59 | id: videoId, 60 | part: "snippet,statistics,contentDetails,status" 61 | }) 62 | 63 | const [video] = response?.data?.items || [] 64 | console.log(video) 65 | if (!video) { 66 | return { error: "Video doesn't exist." }; 67 | } 68 | 69 | if (video.status.privacyStatus === 'private') { 70 | return { error: "Video is private." } 71 | } 72 | 73 | if (!video.status.embeddable) { 74 | return { error: "Video is not embeddable." } 75 | } 76 | 77 | const someLikes = Math.floor(Math.random() * (50 - 10 + 1)) + 10; 78 | return { 79 | video: { 80 | objectID: videoId, 81 | title: video.snippet.title, 82 | recordingDate: dayjs(video.snippet.publishedAt).toDate(), 83 | channelId: video.snippet.channelId, 84 | likes: parseInt(video.statistics.likeCount) || someLikes, 85 | channelTitle: video.snippet.channelTitle, 86 | duration: video.contentDetails.duration, 87 | submissionDate: new Date(), 88 | } 89 | } 90 | } 91 | 92 | async fetchReplies(commentId) { 93 | const response = await this.youtube.comments.list({ 94 | parentId: commentId, 95 | maxResults: 50, 96 | part: "snippet" 97 | }) 98 | return response?.data?.items?.map(mapCommentReply) 99 | } 100 | 101 | async replyToComment(parentId, text) { 102 | const response = await this.youtube.comments.insert({ 103 | part: "snippet", 104 | requestBody: { 105 | snippet: { 106 | parentId, 107 | textOriginal: text 108 | } 109 | } 110 | }) 111 | return mapCommentReply(response.data) 112 | } 113 | 114 | async addComment(videoId, text) { 115 | const response = await this.youtube.commentThreads.insert({ 116 | part: "snippet", 117 | requestBody: { 118 | snippet: { 119 | videoId, 120 | topLevelComment: { 121 | snippet: { 122 | textOriginal: text 123 | } 124 | } 125 | } 126 | } 127 | }) 128 | return mapComment(response.data) 129 | } 130 | 131 | async fetchComments(videoId, pageToken = undefined) { 132 | const response = await this.youtube.commentThreads.list({ 133 | pageToken, 134 | videoId, 135 | maxResults: 50, 136 | order: "relevance", 137 | part: "snippet" 138 | }) 139 | 140 | const nextPageToken = response?.data?.nextPageToken; 141 | const comments = response?.data?.items?.map(mapComment); 142 | 143 | return { 144 | nextPageToken, 145 | comments 146 | } 147 | } 148 | } 149 | 150 | module.exports.Youtube = Youtube 151 | 152 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /vue/src/Watch.vue: -------------------------------------------------------------------------------- 1 | 42 | 61 | 151 | -------------------------------------------------------------------------------- /vue/src/VideoActions.vue: -------------------------------------------------------------------------------- 1 | 59 | 70 | 146 | -------------------------------------------------------------------------------- /vue/src/Search.vue: -------------------------------------------------------------------------------- 1 | 37 | 75 | 172 | -------------------------------------------------------------------------------- /vue/public/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 |
12 |

Privacy Policy

13 |

Eduards Sizovs has built DevTube as an [open 14 | source](https://github.com/watch-devtube/web) app and its code is publicly available for the inspection. This 15 | SERVICE is provided at no cost and is intended for use as is.

16 |

This page is used to inform website visitors regarding our policies with the collection, use, and disclosure of 17 | Personal Information if anyone decided to use our Service.

18 |

If you choose to use our Service, then you agree to the collection and use of information in relation to this 19 | policy. 20 | The Personal Information that we collect is used for providing and improving the Service. We will not use or share 21 | your information with anyone except as described in this Privacy Policy.

22 |

The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible 23 | at 24 | DevTube unless otherwise defined in this Privacy Policy.

25 |

Information Collection and Use

26 |

For better experience, while using our Service, we may require you to provide us with certain personally 27 | identifiable 28 | information, including but not limited to email, first name, last name, contacts. The information that we request 29 | is 30 | will be retained by us and used as described in this privacy policy.

31 |

Cookies

32 |

Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are 33 | sent 34 | to your browser from the websites that you visit and are stored on your device's internal memory. This service 35 | uses cookies to improve your UX. This includes authentication, sorting preference, night mode, and resuming video 36 | from 37 | the last played position. The app may use third party code and libraries that use “cookies” to collect information 38 | and 39 | improve their services. You have the option control cookies in your browser. If you choose to refuse our cookies, 40 | you 41 | may not be able to use some portions of this Service.

42 |

Service Providers

43 |

We may employ third-party companies and individuals due to the following reasons:

44 |
    45 |
  • To facilitate our Service;
  • 46 |
  • To provide the Service on our behalf;
  • 47 |
  • To perform Service-related services; or
  • 48 |
  • To assist us in analyzing how our Service is used.
  • 49 |
50 |

We want to inform users of this Service that these third parties have access to your Personal Information. The 51 | reason 52 | is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the 53 | information for any other purpose.

54 |

Security

55 |

We value your trust in providing us your Personal Information, thus we are striving to use commercially 56 | acceptable 57 | means of protecting it. But remember that no method of transmission over the internet, or method of electronic 58 | storage 59 | is 100% secure and reliable, and we cannot guarantee its absolute security.

60 | 61 |

Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. 62 | Note 63 | that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy 64 | of 65 | these websites. We have no control over and assume no responsibility for the content, privacy policies, or 66 | practices 67 | of any third-party sites or services.

68 |

Children’s Privacy

69 |

These Services do not address anyone under the age of 13. We do not knowingly collect personally identifiable 70 | information from children under 13. In the case we discover that a child under 13 has provided us with personal 71 | information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that 72 | your 73 | child has provided us with personal information, please contact us so that we will be able to do necessary 74 | actions. 75 |

76 |

Changes to This Privacy Policy

77 |

We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for 78 | any 79 | changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are 80 | effective 81 | immediately after they are posted on this page.

82 |

YouTube/Google Privacy Policy

83 |

A user may choose to grant DevTube access to their Google user data by authenticating via Google/Youtube via the 84 | standard OAuth Consent Screen.

85 |

DevTube will then retrieve the username, email, avatar, OAuth Access Token, and OAuth Refresh Token and store 86 | them 87 | securely in the corresponding user browser's Cookies, encrypted with the AES-256 algorithm. The basic user data – 88 | username, email, and avatar – are used to identify the user. The sensitive data – OAuth token – is used to let the 89 | user comment on YouTube videos directly from the DevTube. Because OAuth tokens automatically expire in 7 days, we 90 | use 91 | OAuth Refresh Token to refresh the Access Token after its expiration. DevTube never shares Google user data 92 | with 93 | a 94 | 3rd party and the use of Google user data is strictly limited to what's 95 | described in this privacy policy.

96 | 97 |

DevTube contains data extracted from YouTube API. Related privacy policies:

98 | 102 |

Contact Us

103 |

If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us at 104 | hello+policy@dev.tube

105 |
106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /express/src/libs/Cookies.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const algorithm = 'aes-256-ctr'; 3 | const iv = crypto.randomBytes(16); 4 | 5 | const { COOKIE_SECRET } = process.env; 6 | 7 | const encrypt = (text) => { 8 | const cipher = crypto.createCipheriv(algorithm, COOKIE_SECRET, iv); 9 | const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); 10 | 11 | return iv.toString('hex') + "----" + encrypted.toString('hex') 12 | }; 13 | 14 | const decrypt = (hash) => { 15 | const [iv, content] = hash.split("----"); 16 | const decipher = crypto.createDecipheriv(algorithm, COOKIE_SECRET, Buffer.from(iv, 'hex')); 17 | const decrypted = Buffer.concat([decipher.update(Buffer.from(content, 'hex')), decipher.final()]); 18 | return decrypted.toString(); 19 | }; 20 | 21 | var Buffer = require('safe-buffer').Buffer 22 | var debug = require('debug')('cookie-session') 23 | var Cookies = require('cookies') 24 | var onHeaders = require('on-headers') 25 | 26 | module.exports = cookieSession 27 | 28 | /** 29 | * Create a new cookie session middleware. 30 | * 31 | * @param {object} [options] 32 | * @param {boolean} [options.httpOnly=true] 33 | * @param {array} [options.keys] 34 | * @param {string} [options.name=session] Name of the cookie to use 35 | * @param {boolean} [options.overwrite=true] 36 | * @param {string} [options.secret] 37 | * @param {boolean} [options.signed=true] 38 | * @return {function} middleware 39 | * @public 40 | */ 41 | 42 | function cookieSession(options) { 43 | var opts = options || {} 44 | 45 | // cookie name 46 | var name = opts.name || 'session' 47 | 48 | // secrets 49 | var keys = opts.keys 50 | if (!keys && opts.secret) keys = [opts.secret] 51 | 52 | // defaults 53 | if (opts.overwrite == null) opts.overwrite = true 54 | if (opts.httpOnly == null) opts.httpOnly = true 55 | if (opts.signed == null) opts.signed = true 56 | 57 | if (!keys && opts.signed) throw new Error('.keys required.') 58 | 59 | debug('session options %j', opts) 60 | 61 | return function _cookieSession(req, res, next) { 62 | var cookies = new Cookies(req, res, { 63 | keys: keys 64 | }) 65 | var sess 66 | 67 | // for overriding 68 | req.sessionOptions = Object.create(opts) 69 | 70 | // define req.session getter / setter 71 | Object.defineProperty(req, 'session', { 72 | configurable: true, 73 | enumerable: true, 74 | get: getSession, 75 | set: setSession 76 | }) 77 | 78 | function getSession() { 79 | // already retrieved 80 | if (sess) { 81 | return sess 82 | } 83 | 84 | // unset 85 | if (sess === false) { 86 | return null 87 | } 88 | 89 | // get session 90 | if ((sess = tryGetSession(cookies, name, req.sessionOptions))) { 91 | return sess 92 | } 93 | 94 | // create session 95 | debug('new session') 96 | return (sess = Session.create()) 97 | } 98 | 99 | function setSession(val) { 100 | if (val == null) { 101 | // unset session 102 | sess = false 103 | return val 104 | } 105 | 106 | if (typeof val === 'object') { 107 | // create a new session 108 | sess = Session.create(val) 109 | return sess 110 | } 111 | 112 | throw new Error('req.session can only be set as null or an object.') 113 | } 114 | 115 | onHeaders(res, function setHeaders() { 116 | if (sess === undefined) { 117 | // not accessed 118 | return 119 | } 120 | 121 | try { 122 | if (sess === false) { 123 | // remove 124 | debug('remove %s', name) 125 | cookies.set(name, '', req.sessionOptions) 126 | } else if ((!sess.isNew || sess.isPopulated) && sess.isChanged) { 127 | // save populated or non-new changed session 128 | debug('save %s', name) 129 | cookies.set(name, Session.serialize(sess), req.sessionOptions) 130 | } 131 | } catch (e) { 132 | debug('error saving session %s', e.message) 133 | } 134 | }) 135 | 136 | next() 137 | } 138 | }; 139 | 140 | /** 141 | * Session model. 142 | * 143 | * @param {Context} ctx 144 | * @param {Object} obj 145 | * @private 146 | */ 147 | 148 | function Session(ctx, obj) { 149 | Object.defineProperty(this, '_ctx', { 150 | value: ctx 151 | }) 152 | 153 | if (obj) { 154 | for (var key in obj) { 155 | this[key] = obj[key] 156 | } 157 | } 158 | } 159 | 160 | /** 161 | * Create new session. 162 | * @private 163 | */ 164 | 165 | Session.create = function create(obj) { 166 | var ctx = new SessionContext() 167 | return new Session(ctx, obj) 168 | } 169 | 170 | /** 171 | * Create session from serialized form. 172 | * @private 173 | */ 174 | 175 | Session.deserialize = function deserialize(str) { 176 | var ctx = new SessionContext() 177 | var obj = decode(str) 178 | 179 | ctx._new = false 180 | ctx._val = str 181 | 182 | return new Session(ctx, obj) 183 | } 184 | 185 | /** 186 | * Serialize a session to a string. 187 | * @private 188 | */ 189 | 190 | Session.serialize = function serialize(sess) { 191 | return encode(sess) 192 | } 193 | 194 | /** 195 | * Return if the session is changed for this request. 196 | * 197 | * @return {Boolean} 198 | * @public 199 | */ 200 | 201 | Object.defineProperty(Session.prototype, 'isChanged', { 202 | get: function getIsChanged() { 203 | return this._ctx._new || this._ctx._val !== Session.serialize(this) 204 | } 205 | }) 206 | 207 | /** 208 | * Return if the session is new for this request. 209 | * 210 | * @return {Boolean} 211 | * @public 212 | */ 213 | 214 | Object.defineProperty(Session.prototype, 'isNew', { 215 | get: function getIsNew() { 216 | return this._ctx._new 217 | } 218 | }) 219 | 220 | /** 221 | * populated flag, which is just a boolean alias of .length. 222 | * 223 | * @return {Boolean} 224 | * @public 225 | */ 226 | 227 | Object.defineProperty(Session.prototype, 'isPopulated', { 228 | get: function getIsPopulated() { 229 | return Object.keys(this).length > 0 230 | } 231 | }) 232 | 233 | /** 234 | * Session context to store metadata. 235 | * 236 | * @private 237 | */ 238 | 239 | function SessionContext() { 240 | this._new = true 241 | this._val = undefined 242 | } 243 | 244 | /** 245 | * Decode the base64 cookie value to an object. 246 | * 247 | * @param {String} string 248 | * @return {Object} 249 | * @private 250 | */ 251 | 252 | function decode(string) { 253 | return JSON.parse(decrypt(string)) 254 | } 255 | 256 | /** 257 | * Encode an object into a base64-encoded JSON string. 258 | * 259 | * @param {Object} body 260 | * @return {String} 261 | * @private 262 | */ 263 | 264 | function encode(body) { 265 | return encrypt(JSON.stringify(body)); 266 | // return Buffer.from(str).toString('base64') 267 | } 268 | 269 | /** 270 | * Try getting a session from a cookie. 271 | * @private 272 | */ 273 | 274 | function tryGetSession(cookies, name, opts) { 275 | var str = cookies.get(name, opts) 276 | 277 | if (!str) { 278 | return undefined 279 | } 280 | 281 | debug('parse %s', str) 282 | 283 | try { 284 | return Session.deserialize(str) 285 | } catch (err) { 286 | return undefined 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /vue/src/SubmitVideo.vue: -------------------------------------------------------------------------------- 1 | 80 | 83 | 88 | 207 | -------------------------------------------------------------------------------- /vue/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 147 | 272 | 273 | 304 | 305 | 306 | 307 | 308 |
309 |
310 | Hi. I am error 404. 311 |
312 |
313 | The resource you are looking for does not exist. 314 |
315 |
316 |
317 |
318 | 319 |

Try again from DevTube.
... and follow us on Twitter

321 | 322 |
323 |
324 | 325 | 326 | 436 | 437 | 438 | 441 | 442 | 443 | 444 | 445 | --------------------------------------------------------------------------------