├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── update-base-image.yml │ └── update-image-on-push.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── app.js ├── config.js.template ├── docker-compose.yml ├── inc ├── commons.js ├── compilePostComments.js ├── downloadAndSave.js ├── initRedditApi.js ├── processJsonPost.js ├── processJsonSubreddit.js ├── processJsonUser.js ├── processMoreComments.js ├── processPostMedia.js ├── processSearchResults.js ├── processSubredditAbout.js ├── processSubredditsExplore.js └── teddit_api │ ├── handleSubreddit.js │ └── handleUser.js ├── package-lock.json ├── package.json ├── routes.js ├── static ├── css │ ├── dark.css │ ├── sepia.css │ ├── sprite.png │ └── styles.css ├── favicon.ico ├── favicon.png ├── kopimi.gif ├── pics │ ├── .gitignore │ ├── flairs │ │ └── .gitignore │ ├── icons │ │ └── .gitignore │ └── thumbs │ │ └── .gitignore ├── robots.txt └── vids │ └── .gitignore └── views ├── about.pug ├── includes ├── footer.pug ├── head.pug └── topbar.pug ├── index.pug ├── post.pug ├── preferences.pug ├── privacypolicy.pug ├── saved.pug ├── search.pug ├── subreddit.pug ├── subreddit_wiki.pug ├── subreddits_explore.pug └── user.pug /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/update-base-image.yml: -------------------------------------------------------------------------------- 1 | name: "Update Image and Push to Github Packages and Docker Hub Daily" 2 | on: 3 | schedule: 4 | - cron: "0 12 * * *" # Run every day at noon. 5 | workflow_dispatch: 6 | jobs: 7 | rebuild-container: 8 | name: "Rebuild Container with the latest base image" 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Set up Docker Buildx 13 | uses: docker/setup-buildx-action@v1 14 | - 15 | name: Login to GitHub Container Registry 16 | uses: docker/login-action@v1 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.repository_owner }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - 22 | name: Login to DockerHub 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | - name: "Checkout repository" 28 | uses: "actions/checkout@v2" 29 | - 30 | name: Build and push to Docker Hub and Github Packages Docker Registry 31 | id: docker_build 32 | uses: docker/build-push-action@v2 33 | with: 34 | push: true 35 | tags: | 36 | ghcr.io/${{ github.repository_owner }}/teddit:latest 37 | ${{ secrets.DOCKER_USERNAME }}/teddit:latest 38 | labels: | 39 | org.opencontainers.image.source=${{ github.event.repository.html_url }} 40 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 41 | org.opencontainers.image.revision=${{ github.sha }} 42 | cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/teddit:latest 43 | cache-to: type=inline 44 | - 45 | name: Image digest 46 | run: echo ${{ steps.docker_build.outputs.digest }} -------------------------------------------------------------------------------- /.github/workflows/update-image-on-push.yml: -------------------------------------------------------------------------------- 1 | name: "Update Image and Push to Github Packages and Docker Hub when Dockerfile is changed" 2 | # Run this workflow every time a new commit pushed to your repository 3 | on: 4 | push: 5 | jobs: 6 | rebuild-container: 7 | name: "Rebuild Container with the latest base image" 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | name: Set up Docker Buildx 12 | uses: docker/setup-buildx-action@v1 13 | - 14 | name: Login to GitHub Container Registry 15 | uses: docker/login-action@v1 16 | with: 17 | registry: ghcr.io 18 | username: ${{ github.repository_owner }} 19 | password: ${{ secrets.GITHUB_TOKEN }} 20 | - 21 | name: Login to DockerHub 22 | uses: docker/login-action@v1 23 | with: 24 | username: ${{ secrets.DOCKER_USERNAME }} 25 | password: ${{ secrets.DOCKER_PASSWORD }} 26 | - name: "Checkout repository" 27 | uses: "actions/checkout@v2" 28 | - 29 | name: Build and push to Docker Hub and Github Packages Docker Registry 30 | id: docker_build 31 | uses: docker/build-push-action@v2 32 | with: 33 | push: true 34 | tags: | 35 | ghcr.io/${{ github.repository_owner }}/teddit:latest 36 | ${{ secrets.DOCKER_USERNAME }}/teddit:latest 37 | labels: | 38 | org.opencontainers.image.source=${{ github.event.repository.html_url }} 39 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 40 | org.opencontainers.image.revision=${{ github.sha }} 41 | cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/teddit:latest 42 | cache-to: type=inline 43 | - 44 | name: Image digest 45 | run: echo ${{ steps.docker_build.outputs.digest }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | config.js 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 2021-03-23 3 | ### Fixed 4 | 1. fix /r/:subreddit/search redis key. 5 | ## 2021-03-22 6 | ### Added 7 | 1. add favicon.ico and display profile icons 8 | ## 2021-03-21 9 | ### Added 10 | 1. Add support for /poll urls 11 | 2. Replace reddit links by default 12 | 3. Add missing user_preferences parameter 13 | 4. add replacePrivacyDomains() 14 | 5. Replace youtube/twitter/instagram with privacy respecting ones 15 | ### Fixed 16 | 1. Fix export feature when not exporting saved posts 17 | 2. Ignore urls starting with /r/ 18 | ## 2021-03-20 19 | ### Fixed 20 | 1. Rewrite redd.it and its subdomains to teddit domain, fixes (#120) 21 | 2. Replace all (*.)reddit.com domains with teddit domain (#120) 22 | ## 2021-03-19 23 | ### Added 24 | 1. Add feature to export and import preferences as json file 25 | ### Fixed 26 | 1. Fix search page (PR #164) 27 | 2. Fix Pug security vulns. see more PR #165 28 | ## 2021-03-18 29 | 1.Add a feature for showing upvote ratio in posts #147 30 | ## 2021-03-16 31 | ## Added 32 | 1. Add a feature to the preferences where users can choose to collapse child comments automatically 33 | ## 2021-03-15 34 | ### Added 35 | 1. Add support for user created custom fields (#162) 36 | ## 2021-03-01 37 | ### Added 38 | 1. Make a preference for image sizes in posts, fix #119 39 | - Now users can change the max height of the media in posts. In preferences there is a setting called "Media size in posts". For more info, click [here](https://codeberg.org/teddit/teddit/issues/119#issuecomment-180384) 40 | ## 2021-02-23 41 | ### Fixed 42 | 1. Fix next page bug if NSFW is turned on 43 | ## 2021-02-19 44 | ### Fixed 45 | 1. Improve support for gyfcat(fix #15) 46 | ## 2021-02-14 47 | ### Fixed 48 | 1. fix spoiler text not always showing when focused 49 | ## 2021-02-13 50 | ### Added 51 | 1. Include saved posts in export preferences 52 | ## 2021-02-06 53 | ### Added 54 | 1. Save Post feature 55 | - You can now save/bookmark posts! Click the **saved** button on the subreddit bar to view the bookmarked posts. 56 | ### Fixed 57 | 1. Fix error message for empty/saved 58 | 2. Fix main post links 59 | ## 2021-01-31 60 | ### Added 61 | 1. Support for short Gallery URLs 62 | ## 2021-01-30 63 | ### Added 64 | 1. add import/export feature for preferences 65 | - You can now transfer your preferences and subreddits to another device. To export preferences, go to **Preferences** --> click the arrow next to **Export Preferences**. Access the URL from another device to import your preferences and subreddits. 66 | 2. add 'pubDate' for RSS feeds 67 | ## 2021-01-29 68 | ### Added 69 | 1. filter users by submissions/comments 70 | ### Fixed 71 | 1. fixes for #139 72 | 2. Fix expandable and image overflow for sepia theme 73 | ## 2021-01-28 74 | ### Fixed 75 | 1. Fine tune expandable post 76 | ## 2021-01-27 77 | ### Added 78 | 1. Automatically change theme 79 | ### Fixed 80 | 1. Fix expanding post (#137) 81 | ## 2021-01-23 82 | ### Fixed 83 | 1. Styling of footer (PR: #132) 84 | 2. Fix (#130) - Placement of buttons. 85 | ## 2021-01-22 86 | ### Fixed 87 | 1. Fix short comment URLs(#114) 88 | 2. Fix unescape's regex(#128) 89 | 3. Optimize CSS for narrow devices(#123) 90 | ## 2021-01-21 91 | ### Fixed 92 | 1. Styling of sepia theme 93 | ## 2021-01-20 94 | ### Added 95 | 1. Added Teddit logo to the topbar 96 | 2. Sepia theme 97 | - Teddit has a new beautiful Sepia theme. Go to preferences to change the theme. 98 | ## 2021-01-19 99 | ### Added 100 | 1. Expand text posts from subreddit view 101 | - Now you can expand/preview text posts by clicking on the hamburger icon. 102 | ### Fixed 103 | 1. Check that Gallery ID exists 104 | 2. Optimized CSS 105 | ## 2021-01-17 106 | ### Added 107 | 1. Support for r/random 108 | 2. add '/subreddits' 109 | - Now you can search and explore subreddits easily. Add '/subreddits' to the URL or press the **more** button in the top bar. 110 | ## 2021-01-16 111 | ### Added 112 | - Convert reddit.com links to instance domain 113 | ## 2021-01-15 114 | ### Added 115 | - scroll to infobar when viewing comment permalink (#78) 116 | ### Fixed 117 | - Fix sidebar overflow on mobile (#109) 118 | ## 2021-01-12 119 | ### Added 120 | - Added r/popular to list of subreddits 121 | ## 2021-01-10 122 | ### Added 123 | - Edit date for comments 124 | ### Fixed 125 | - Position of subscribe button in mobile 126 | - Inconsistency of Link colours 127 | ## 2021-01-09 128 | ### Added 129 | - User info on top of entries 130 | - r/all even when users have subscriptions 131 | ### Fixed 132 | - Previous/Next links on page. 133 | ## 2021-01-08 134 | ### Added 135 | - Subscribe to subreddits and manage subscriptions from preferences page. 136 | ### Fixed 137 | - Fixed subreddit view when there are no subscriptions. 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use LTS Node.js base image 2 | FROM node:14.16-alpine 3 | 4 | # Video support dependency 5 | RUN apk add ffmpeg 6 | 7 | # Install NPM dependencies and copy the project 8 | WORKDIR /teddit 9 | COPY . ./ 10 | RUN npm install --no-optional 11 | COPY config.js.template ./config.js 12 | 13 | RUN find ./static/ -type d -exec chmod -R 777 {} \; 14 | 15 | CMD npm start 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS IS ONLY A MIRROR REPO! 2 | 3 | Main repository at Codeberg: https://codeberg.org/teddit/teddit 4 | 5 | Please submit issues and PRs on Codeberg. 6 | 7 | # teddit 8 | 9 | [teddit.net](https://teddit.net) 10 | 11 | A free and open source alternative Reddit front-end focused on privacy. 12 | Inspired by the [Nitter](https://github.com/zedeus/nitter) project. 13 | 14 | * No JavaScript or ads 15 | * All requests go through the backend, client never talks to Reddit 16 | * Prevents Reddit from tracking your IP or JavaScript fingerprint 17 | * [Unofficial API](https://codeberg.org/teddit/teddit/wiki#teddit-api) (RSS & JSON support, no rate limits or Reddit account required) 18 | * Lightweight (teddit frontpage: ~30 HTTP requests with ~270 KB of data downloaded vs. Reddit frontpage: ~190 requests with ~24 MB) 19 | * Self-hostable. Anyone can setup an instance. An instance can either use Reddit's API with or without OAuth (so Reddit API key is not necessarily needed). 20 | 21 | Join the teddit discussion room on Matrix: [#teddit:matrix.org](https://matrix.to/#/#teddit:matrix.org) 22 | 23 | XMR: 832ogRwuoSs2JGYg7wJTqshidK7dErgNdfpenQ9dzMghNXQTJRby1xGbqC3gW3GAifRM9E84J91VdMZRjoSJ32nkAZnaCEj 24 | 25 | ## Instances 26 | 27 | [https://teddit.net](https://teddit.net) - Official instance 28 | 29 | Community instances: 30 | 31 | * [https://teddit.ggc-project.de](https://teddit.ggc-project.de) 32 | * [https://teddit.kavin.rocks](https://teddit.kavin.rocks) 33 | * [https://teddit.zaggy.nl](https://teddit.zaggy.nl) 34 | * [https://teddit.namazso.eu](https://teddit.namazso.eu) 35 | * [https://teddit.nautolan.racing](https://teddit.nautolan.racing) 36 | * [https://teddit.tinfoil-hat.net](https://teddit.tinfoil-hat.net) 37 | * [https://teddit.domain.glass](https://teddit.domain.glass) 38 | * [ibarajztopxnuhabfu7f...onion](http://ibarajztopxnuhabfu7fg6gbudynxofbnmvis3ltj6lfx47b6fhrd5qd.onion) 39 | * [xugoqcf2pftm76vbznx4...i2p](http://xugoqcf2pftm76vbznx4xuhrzyb5b6zwpizpnw2hysexjdn5l2tq.b32.i2p) 40 | 41 | ## Changelog 42 | 43 | See ```CHANGELOG.md``` 44 | ## Installation 45 | 46 | ### Docker-compose method 47 | 48 | ```console 49 | wget https://codeberg.org/teddit/teddit/raw/branch/main/docker-compose.yml 50 | docker-compose build 51 | docker-compose up 52 | ``` 53 | 54 | Teddit should now be running at . 55 | 56 | Docker image is available at [https://hub.docker.com/r/teddit/teddit](https://hub.docker.com/r/teddit/teddit). 57 | 58 | #### Environment Variables 59 | 60 | The following variables may be set to customize your deployment at runtime. 61 | 62 | | Variable | Description | 63 | |-|-| 64 | | domain | Defines URL for Teddit to use (i.e. teddit.domain.com). Defaults to **127.0.0.1** | 65 | | use_reddit_oauth | *Boolean* If true, "reddit_app_id" must be set with your own Reddit app ID. If false, Teddit uses Reddit's public API. Defaults to **false** | 66 | | cert_dir | Defines location of certificates if using HTTPS (i.e. /home/teddit/le/live/teddit.net). No trailing slash. | 67 | | theme | Automatically theme the user's browser experience. Options are *auto*, *dark*, *sepia*, or you can set *white* by setting the variable to empty ( '' ). Defaults to **auto** | 68 | | flairs_enabled | Enables the rendering of user and link flairs on Teddit. Defaults to **true** | 69 | | highlight_controversial | Enables controversial comments to be indicated by a typographical dagger (†). Defaults to **true** | 70 | | api_enabled | Teddit API feature. Might increase loads significantly on your instance. Defaults to **true** | 71 | | video_enabled | Enables video playback within Teddit. Defaults to **true** | 72 | | redis_enabled | Enables Redis caching. If disabled, does not allow for any caching of Reddit API calls. Defaults to **true** | 73 | | redis_db | Sets the redis DB name, if required | 74 | | redis_host | Sets the redis host location, if required. Defaults to **127.0.0.1** | 75 | | redis_password | Sets the redis password, if required | 76 | | redis_port | Sets the redis port, if required. Defaults to **6379** | 77 | | ssl_port | Sets the SSL port Teddit listens on. Defaults to **8088** | 78 | | nonssl_port | Sets the non-SSL port Teddit listens on. Defaults to **8080** | 79 | | listen_address | Sets the address Teddit listens for requests on. Defaults to **0.0.0.0** | 80 | | https_enabled | *Boolean* Sets whether or not to enable HTTPS for Teddit. Defaults to **false** | 81 | | redirect_http_to_https | *Boolean* Sets whether to force redirection from HTTP to HTTPS. Defaults to **false** | 82 | | redirect_www | *Boolean* Redirects from www to non-www URL. For example, if true, Teddit will redirect https://www.teddit.com to https://teddit.com. Defaults to **false** | 83 | | use_compression | *Boolean* If set to true, Teddit will use the [https://github.com/expressjs/compression](Node.js compression middleware) to compress HTTP requests with deflate/gzip. Defaults to **true** | 84 | | use_view_cache | *Boolean* If this is set to true, view template compilation caching is enabled. Defaults to **false** | 85 | | use_helmet | *Boolean* Recommended to be true when using https. Defaults to **false** | 86 | | use_helmet_hsts | *Boolean* Recommended to be true when using https. Defaults to **false** | 87 | | trust_proxy | *Boolean* Enable trust_proxy if you are using a reverse proxy like nginx or traefik. Defaults to **false** | 88 | | trust_proxy_address | Location of trust_proxy. Defaults to **127.0.0.1** | 89 | | nsfw_enabled | *Boolean* Enable NSFW (over 18) content. If false, a warning is shown to the user before opening any NSFW post. When the NFSW content is disabled, NSFW posts are hidden from subreddits and from user page feeds. Note: Users can set this to true or false from their preferences. Defaults to **true** | 90 | | post_comments_sort | Defines default sort preference. Options are *confidence* (default sorting option in Reddit), *top*, *new*, *controversal*, *old*, *random*, *qa*, *live*. Defaults to **confidence** | 91 | | reddit_app_id | If "use_reddit_oauth" config key is set to true, you have to obtain your Reddit app ID. For testing purposes it's okay to use this project's default app ID. Create your Reddit app here: https://old.reddit.com/prefs/apps/. Make sure to create an "installed app" type of app. Default is **ABfYqdDc9qPh1w** | 92 | 93 | ### Manual 94 | 95 | 1. Install [Node.js](https://nodejs.org). 96 | 97 | 1. (Optional) Install [redis-server](https://redis.io). 98 | 99 | Highly recommended – it works as a cache for Reddit API calls. 100 | 101 | 1. (Optional) Install [ffmpeg](https://ffmpeg.org). 102 | 103 | It's needed if you want to support videos. 104 | 105 | ```console 106 | # Linux 107 | apt install redis-server ffmpeg 108 | 109 | # macOS 110 | brew install redis 111 | ``` 112 | 113 | 1. Clone and set up the repository. 114 | 115 | ```console 116 | git clone https://codeberg.org/teddit/teddit 117 | cd teddit 118 | npm install --no-optional 119 | cp config.js.template config.js # edit the file to suit your environment 120 | redis-server 121 | npm start 122 | ``` 123 | 124 | Teddit should now be running at . 125 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const config = require('./config') 2 | 3 | global.client_id_b64 = Buffer.from(`${config.reddit_app_id}:`).toString('base64') 4 | global.reddit_access_token = null 5 | global.reddit_refresh_token = null 6 | global.ratelimit_counts = {} 7 | global.ratelimit_timestamps = {} 8 | 9 | const pug = require('pug') 10 | const compression = require('compression') 11 | const express = require('express') 12 | const cookieParser = require('cookie-parser') 13 | const r = require('redis') 14 | 15 | const redis = (() => { 16 | if (!config.redis_enabled) { 17 | // Stub Redis if disabled 18 | return { 19 | get: (_, callback) => callback(null, null), 20 | setex: (_, _1, _2, callback) => callback(null), 21 | on: () => {} 22 | } 23 | } 24 | 25 | const redisOptions = { 26 | host: '127.0.0.1', 27 | port: 6379 28 | } 29 | 30 | if (config.redis_db) { 31 | redisOptions.db = config.redis_db 32 | } 33 | 34 | if (config.redis_host) { 35 | redisOptions.host = config.redis_host 36 | } 37 | 38 | if (config.redis_port && config.redis_port > 0) { 39 | redisOptions.port = config.redis_port 40 | } 41 | 42 | if (config.redis_password) { 43 | redisOptions.password = config.redis_password 44 | } 45 | 46 | return r.createClient(redisOptions) 47 | })() 48 | const helmet = require('helmet') 49 | const bodyParser = require('body-parser') 50 | const fetch = require('node-fetch') 51 | const fs = require('fs') 52 | const app = express() 53 | const request = require('postman-request') 54 | const commons = require('./inc/commons.js')(request, fs) 55 | const dlAndSave = require('./inc/downloadAndSave.js')(commons); 56 | 57 | ['pics/thumbs', 'pics/flairs', 'pics/icons', 'vids'] 58 | .map(d => `./static/${d}`) 59 | .filter(d => !fs.existsSync(d)) 60 | .forEach(d => fs.mkdirSync(d, { recursive: true })) 61 | 62 | if(!config.https_enabled && config.redirect_http_to_https) { 63 | console.error(`Cannot redirect HTTP=>HTTPS while "https_enabled" is false.`) 64 | } 65 | 66 | let https = null 67 | if(config.https_enabled) { 68 | const privateKey = fs.readFileSync(`${config.cert_dir}/privkey.pem`, 'utf8') 69 | const certificate = fs.readFileSync(`${config.cert_dir}/cert.pem`, 'utf8') 70 | const ca = fs.readFileSync(`${config.cert_dir}/chain.pem`, 'utf8') 71 | const credentials = { 72 | key: privateKey, 73 | cert: certificate, 74 | ca: ca 75 | } 76 | https = require('https').Server(credentials, app) 77 | global.protocol = 'https://' 78 | } else { 79 | global.protocol = 'http://' 80 | } 81 | 82 | const http = require('http').Server(app) 83 | 84 | if(config.redirect_www) { 85 | app.use((req, res, next) => { 86 | if(req.headers.host) { 87 | if(req.headers.host.slice(0, 4) === 'www.') { 88 | let newhost = req.headers.host.slice(4) 89 | return res.redirect(301, `${req.protocol}://${newhost}${req.originalUrl}`) 90 | } 91 | } 92 | next() 93 | }) 94 | } 95 | 96 | if(config.use_helmet && config.https_enabled) { 97 | app.use(helmet()) 98 | if(config.use_helmet_hsts) { 99 | app.use(helmet.hsts({ maxAge: 31536000, preload: true })) 100 | } 101 | } 102 | 103 | if(config.use_compression) { 104 | app.use(compression()) 105 | } 106 | 107 | app.use(cookieParser()) 108 | 109 | if(config.use_view_cache) { 110 | app.set('view cache', true) 111 | } 112 | 113 | if(config.trust_proxy) { 114 | app.set('trust proxy', config.trust_proxy_address) 115 | } 116 | 117 | app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' })) 118 | app.use(bodyParser.json({ limit: '10mb' })) 119 | app.use(express.static(`${__dirname}/static`)) 120 | 121 | app.set('views', './views') 122 | app.set('view engine', 'pug') 123 | 124 | if(config.redirect_http_to_https) { 125 | app.use((req, res, next) => { 126 | if(req.secure) 127 | next() 128 | else 129 | res.redirect(`https://${req.headers.host}${req.url}`) 130 | }) 131 | } 132 | 133 | const redditAPI = require('./inc/initRedditApi.js')(fetch) 134 | require('./routes')(app, redis, fetch, redditAPI) 135 | 136 | redis.on('error', (error) => { 137 | if(error) { 138 | console.error(`Redis error: ${error}`) 139 | } 140 | }) 141 | 142 | if(config.https_enabled) { 143 | https.listen(config.ssl_port, '::', () => console.log(`Teddit running on https://${config.domain}:${config.ssl_port}`)) 144 | } 145 | http.listen(config.nonssl_port, '::', () => console.log(`Teddit running on http://${config.domain}:${config.nonssl_port}`)) 146 | -------------------------------------------------------------------------------- /config.js.template: -------------------------------------------------------------------------------- 1 | const config = { 2 | domain: process.env.DOMAIN || '127.0.0.1', // Or for example 'teddit.net' 3 | use_reddit_oauth: process.env.USE_REDDIT_OAUTH === 'true' || false, // If false, teddit uses Reddit's public API. If true, you need to have your own Reddit app ID (enter the app ID to the "reddit_app_id" config key). 4 | cert_dir: process.env.CERT_DIR || '', // For example '/home/teddit/letsencrypt/live/teddit.net', if you are using https. No trailing slash. 5 | theme: process.env.THEME || 'auto', // One of: 'dark', 'sepia', 'auto', ''. Auto theme uses browser's theme detection (Dark or White theme). White theme is set by the empty the option (''). 6 | flairs_enabled: process.env.FLAIRS_ENABLED !== 'true' || true, // Enables the rendering of user and link flairs on teddit 7 | highlight_controversial: process.env.HIGHLIGHT_CONTROVERSIAL !== 'true' || true, // Enables controversial comments to be indicated by a typographical dagger (†) 8 | api_enabled: process.env.API_ENABLED !== 'true' || true, // Teddit API feature. Might increase loads significantly on your instance. 9 | video_enabled: process.env.VIDEO_ENABLED !== 'true' || true, 10 | redis_enabled: process.env.REDIS_ENABLED !== 'true' || true, // If disabled, does not cache Reddit API calls 11 | redis_db: process.env.REDIS_DB, 12 | redis_host: process.env.REDIS_HOST || '127.0.0.1', 13 | redis_password: process.env.REDIS_PASSWORD, 14 | redis_port: process.env.REDIS_PORT || 6379, 15 | ssl_port: process.env.SSL_PORT || 8088, 16 | nonssl_port: process.env.NONSSL_PORT || 8080, 17 | listen_address: process.env.LISTEN_ADDRESS || '0.0.0.0', 18 | https_enabled: process.env.HTTPS_ENABLED === 'true' || false, 19 | redirect_http_to_https: process.env.REDIRECT_HTTP_TO_HTTPS === 'true' || false, 20 | redirect_www: process.env.REDIRECT_WWW === 'true' || false, 21 | use_compression: process.env.USE_COMPRESSION !== 'true' || true, 22 | use_view_cache: process.env.USE_VIEW_CACHE === 'true' || false, 23 | use_helmet: process.env.USE_HELMET === 'true' || false, // Recommended to be true when using https 24 | use_helmet_hsts: process.env.USE_HELMET_HSTS === 'true' || false, // Recommended to be true when using https 25 | trust_proxy: process.env.TRUST_PROXY === 'true' || false, // Enable trust_proxy if you are using reverse proxy like nginx 26 | trust_proxy_address: process.env.TRUST_PROXY_ADDRESS || '127.0.0.1', 27 | nsfw_enabled: process.env.NSFW_ENABLED !== 'true' || true, // Enable NSFW (over 18) content. If false, a warning is shown to the user before opening any NSFW post. When the NFSW content is disabled, NSFW posts are hidden from subreddits and from user page feeds. Note: Users can set this to true or false from their preferences. 28 | post_comments_sort: process.env.POST_COMMENTS_SORT || 'confidence', // "confidence" is the default sorting in Reddit. Must be one of: confidence, top, new, controversial, old, random, qa, live. 29 | reddit_app_id: process.env.REDDIT_APP_ID || 'ABfYqdDc9qPh1w', // If "use_reddit_oauth" config key is set to true, you have to obtain your Reddit app ID. For testing purposes it's okay to use this project's default app ID. Create your Reddit app here: https://old.reddit.com/prefs/apps/. Make sure to create an "installed app" type of app. 30 | domain_replacements: process.env.DOMAIN_REPLACEMENTS 31 | ? (JSON.parse(process.env.DOMAIN_REPLACEMENTS).map(([p, r]) => [new RegExp(p, 'gm'), r])) 32 | : [], // Replacements for domains in outgoing links. Tuples with regular expressions to match, and replacement values. This is in addition to user-level configuration of privacyDomains. 33 | post_media_max_heights: { 34 | /** 35 | * Sets the max-height value for images and videos in posts. 36 | * Default is 'medium'. 37 | */ 38 | 'extra-small': 300, 39 | 'small': 415, 40 | 'medium': 600, 41 | 'large': 850, 42 | 'extra-large': 1200 43 | }, 44 | setexs: { 45 | /** 46 | * Redis cache expiration values (in seconds). 47 | * When the cache expires, new content is fetched from Reddit's API (when 48 | * the given URL is revisited). 49 | */ 50 | frontpage: 600, 51 | subreddit: 600, 52 | posts: 600, 53 | user: 600, 54 | searches: 600, 55 | sidebar: 60 * 60 * 24 * 7, // 7 days 56 | shorts: 60 * 60 * 24 * 31, 57 | wikis: 60 * 60 * 24 * 7, 58 | subreddits_explore: { 59 | front: 60 * 60 * 24 * 1, 60 | new_page: 60 61 | }, 62 | }, 63 | rate_limiting: { 64 | enabled: false, 65 | initial_limit: 100, // This is the amount of page loads one IP address can make in one minute without getting limited. 66 | limit_after_limited: 30 // When an IP is limited, this is the amount of page loads the IP can make in one minute. 67 | }, 68 | valid_media_domains: ['preview.redd.it', 'external-preview.redd.it', 'i.redd.it', 'v.redd.it', 'a.thumbs.redditmedia.com', 'b.thumbs.redditmedia.com', 'emoji.redditmedia.com', 'styles.redditmedia.com', 'www.redditstatic.com', 'thumbs.gfycat.com', 'i.ytimg.com'], 69 | valid_embed_video_domains: ['gfycat.com', 'youtube.com'], 70 | reddit_api_error_text: `Seems like your instance is either blocked (e.g. due to API rate limiting), reddit is currently down, or your API key is expired and not renewd properly. This can also happen for other reasons.` 71 | }; 72 | 73 | module.exports = config; 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | redis: 5 | image: redis:6.0.9-alpine3.12 6 | command: redis-server 7 | environment: 8 | - REDIS_REPLICATION_MODE=master 9 | ports: 10 | - "6379:6379" 11 | networks: 12 | - teddit_net 13 | web: 14 | build: . 15 | environment: 16 | - REDIS_HOST=redis 17 | ports: 18 | - 8080:8080 19 | networks: 20 | - teddit_net 21 | healthcheck: 22 | test: ["CMD", "wget" ,"--no-verbose", "--tries=1", "--spider", "http://localhost:8080/about"] 23 | interval: 1m 24 | timeout: 3s 25 | depends_on: 26 | - redis 27 | networks: 28 | teddit_net: 29 | -------------------------------------------------------------------------------- /inc/commons.js: -------------------------------------------------------------------------------- 1 | module.exports = function(request, fs) { 2 | const config = require('../config') 3 | this.downloadFile = (url) => { 4 | return new Promise(resolve => { 5 | request(url, { encoding: 'binary' }, (error, response, body) => { 6 | if(!error && response.statusCode === 200) { 7 | resolve({ success: true, data: body }) 8 | } else { 9 | resolve({ success: false, data: null }) 10 | } 11 | }) 12 | }).catch((err) => { 13 | console.log(`Downloading media file failed for unknown reason. Details:`, err) 14 | }) 15 | } 16 | 17 | this.writeToDisk = (data, filename) => { 18 | return new Promise(resolve => { 19 | fs.writeFile(filename, data, 'binary', (error, result) => { 20 | if(!error) { 21 | resolve({ success: true }) 22 | } else { 23 | resolve({ success: false, error }) 24 | } 25 | }) 26 | }).catch((err) => { 27 | console.log(`Writing media file to disk failed for unknown reason. Details:`, err) 28 | }) 29 | } 30 | 31 | this.logTimestamp = (date) => { 32 | return date.toGMTString() 33 | } 34 | 35 | this.cleanUrl = (url) => { 36 | return url.replace(/&/g, '&') 37 | } 38 | 39 | this.teddifyUrl = (url, user_preferences) => { 40 | try { 41 | let u = new URL(url) 42 | if(u.host === 'www.reddit.com' || u.host === 'reddit.com') { 43 | url = url.replace(u.host, config.domain) 44 | if(u.pathname.startsWith('/gallery/')) 45 | url = url.replace('/gallery/', '/comments/') 46 | } 47 | if(u.host === 'i.redd.it' || u.host === 'v.redd.it') { 48 | let image_exts = ['png', 'jpg', 'jpeg'] 49 | let video_exts = ['mp4', 'gif', 'gifv'] 50 | let file_ext = getFileExtension(url) 51 | if(image_exts.includes(file_ext)) 52 | url = url.replace(`${u.host}/`, `${config.domain}/pics/w:null_`) 53 | if(video_exts.includes(file_ext) || !image_exts.includes(file_ext)) 54 | url = url.replace(u.host, `${config.domain}/vids`) + '.mp4' 55 | } 56 | 57 | } catch(e) { } 58 | url = replaceDomains(url, user_preferences) 59 | return url 60 | } 61 | 62 | this.kFormatter = (num) => { 63 | return Math.abs(num) > 999 ? Math.sign(num)*((Math.abs(num)/1000).toFixed(1)) + 'k' : Math.sign(num)*Math.abs(num) 64 | } 65 | 66 | this.timeDifference = (time, hide_suffix) => { 67 | time = parseInt(time) * 1000 68 | let ms_per_minute = 60 * 1000 69 | let ms_per_hour = ms_per_minute * 60 70 | let ms_per_day = ms_per_hour * 24 71 | let ms_per_month = ms_per_day * 30 72 | let ms_per_year = ms_per_day * 365 73 | let current = + new Date() 74 | let suffix = 'ago' 75 | 76 | if(hide_suffix) 77 | suffix = '' 78 | 79 | let elapsed = Math.abs(time - current) 80 | let r = '' 81 | let e 82 | 83 | if(elapsed < ms_per_minute) { 84 | e = Math.round(elapsed/1000) 85 | r = `${e} seconds ${suffix}` 86 | if(e === 1) 87 | r = 'just now' 88 | return r 89 | } 90 | 91 | else if(elapsed < ms_per_hour) { 92 | e = Math.round(elapsed/ms_per_minute) 93 | r = `${e} minutes ${suffix}` 94 | if(r === 1) 95 | r = `a minute ${suffix}` 96 | return r 97 | } 98 | 99 | else if(elapsed < ms_per_day ) { 100 | e = Math.round(elapsed/ms_per_hour) 101 | r = `${e} hours ${suffix}` 102 | if(e === 1) 103 | r = `an hour ${suffix}` 104 | return r 105 | } 106 | 107 | else if(elapsed < ms_per_month) { 108 | e = Math.round(elapsed/ms_per_day) 109 | r = `${e} days ${suffix}` 110 | if(e === 1) 111 | r = `1 day ${suffix}` 112 | return r 113 | } 114 | 115 | else if(elapsed < ms_per_year) { 116 | e = Math.round(elapsed/ms_per_month) 117 | r = `${e} months ${suffix}` 118 | if(e === 1) 119 | r = `1 month ${suffix}` 120 | return r 121 | } 122 | 123 | else { 124 | e = Math.round(elapsed/ms_per_year) 125 | r = `${e} years ${suffix}` 126 | if(e === 1) 127 | r = `1 year ${suffix}` 128 | return r 129 | } 130 | } 131 | 132 | this.toUTCString = (time) => { 133 | let d = new Date() 134 | d.setTime(time*1000) 135 | return d.toUTCString() 136 | } 137 | 138 | 139 | 140 | this.unescape = (s, user_preferences) => { 141 | /* It would make much more sense to rename this function to something 142 | * like "formatter". 143 | */ 144 | if(s) { 145 | var re = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g; 146 | var unescaped = { 147 | '&': '&', 148 | '&': '&', 149 | '<': '<', 150 | '<': '<', 151 | '>': '>', 152 | '>': '>', 153 | ''': "'", 154 | ''': "'", 155 | '"': '"', 156 | '"': '"' 157 | } 158 | let result = s.replace(re, (m) => { 159 | return unescaped[m] 160 | }) 161 | 162 | result = replaceDomains(result, user_preferences) 163 | 164 | return result 165 | } else { 166 | return '' 167 | } 168 | } 169 | 170 | this.replaceDomains = (str, user_preferences) => { 171 | if(typeof(str) == 'undefined' || !str) 172 | return 173 | 174 | if (config.domain_replacements) { 175 | for (replacement of config.domain_replacements) { 176 | str = str.replace(...replacement) 177 | } 178 | } 179 | 180 | return this.replaceUserDomains(str, user_preferences) 181 | } 182 | 183 | this.replaceUserDomains = (str, user_preferences) => { 184 | 185 | let redditRegex = /([A-z.]+\.)?(reddit(\.com)|redd(\.it))/gm; 186 | let youtubeRegex = /([A-z.]+\.)?youtu(be\.com|\.be)/gm; 187 | let twitterRegex = /([A-z.]+\.)?twitter\.com/gm; 188 | let instagramRegex = /([A-z.]+\.)?instagram.com/gm; 189 | 190 | str = str.replace(redditRegex, config.domain) 191 | 192 | if(typeof(user_preferences) == 'undefined') 193 | return str 194 | 195 | if(typeof(user_preferences.domain_youtube) != 'undefined') 196 | if(user_preferences.domain_youtube) 197 | str = str.replace(youtubeRegex, user_preferences.domain_youtube) 198 | 199 | if(typeof(user_preferences.domain_twitter) != 'undefined') 200 | if(user_preferences.domain_twitter) 201 | str = str.replace(twitterRegex, user_preferences.domain_twitter) 202 | 203 | if(typeof(user_preferences.domain_instagram) != 'undefined') 204 | if(user_preferences.domain_instagram) 205 | str = str.replace(instagramRegex, user_preferences.domain_instagram) 206 | 207 | return str 208 | } 209 | 210 | this.deleteFiles = (files, callback) => { 211 | var i = files.length 212 | files.forEach((filepath) => { 213 | fs.unlink(filepath, (err) => { 214 | i-- 215 | if(err) { 216 | callback(err) 217 | return 218 | } else if(i <= 0) { 219 | callback(null) 220 | } 221 | }) 222 | }) 223 | } 224 | 225 | this.isGif = (url) => { 226 | if(url.startsWith('/r/')) 227 | return false 228 | 229 | try { 230 | url = new URL(url) 231 | let pathname = url.pathname 232 | let file_ext = pathname.substring(pathname.lastIndexOf('.') + 1) 233 | if(file_ext === 'gif' || file_ext === 'gifv') { 234 | return true 235 | } else { 236 | return false 237 | } 238 | } catch (error) { 239 | console.error(`Invalid url supplied to isGif(). URL: ${url}`, error) 240 | return false 241 | } 242 | } 243 | 244 | this.getFileExtension = (url) => { 245 | try { 246 | url = new URL(url) 247 | let pathname = url.pathname 248 | let file_ext = pathname.substring(pathname.lastIndexOf('.') + 1) 249 | if(file_ext) { 250 | return file_ext 251 | } else { 252 | return '' 253 | } 254 | } catch (error) { 255 | console.error(`Invalid url supplied to getFileExtension(). URL: ${url}`, error) 256 | return '' 257 | } 258 | } 259 | 260 | this.formatLinkFlair = async (post) => { 261 | if (!config.flairs_enabled) { 262 | return '' 263 | } 264 | 265 | const wrap = (inner) => `${inner}` 266 | 267 | if (post.link_flair_text === null) 268 | return '' 269 | 270 | if (post.link_flair_type === 'text') 271 | return wrap(post.link_flair_text) 272 | 273 | if (post.link_flair_type === 'richtext') { 274 | let flair = '' 275 | for (let fragment of post.link_flair_richtext) { 276 | if (fragment.e === 'text') 277 | flair += fragment.t 278 | else if (fragment.e === 'emoji') 279 | flair += `` 280 | } 281 | return wrap(flair) 282 | } 283 | 284 | return '' 285 | } 286 | 287 | this.formatUserFlair = async (post) => { 288 | if (!config.flairs_enabled) { 289 | return '' 290 | } 291 | 292 | // Generate the entire HTML here for consistency in both pug and HTML 293 | const wrap = (inner) => `${inner}` 294 | 295 | if (post.author_flair_text === null) 296 | return '' 297 | 298 | if (post.author_flair_type === 'text') 299 | return wrap(post.author_flair_text) 300 | 301 | if (post.author_flair_type === 'richtext') { 302 | let flair = '' 303 | for (let fragment of post.author_flair_richtext) { 304 | // `e` seems to mean `type` 305 | if (fragment.e === 'text') 306 | flair += fragment.t // `t` is the text 307 | else if (fragment.e === 'emoji') 308 | flair += `` // `u` is the emoji URL 309 | } 310 | return wrap(flair) 311 | } 312 | 313 | return '' 314 | } 315 | 316 | } 317 | -------------------------------------------------------------------------------- /inc/compilePostComments.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | this.compilePostCommentsHtml = (comments, next_comment, post_id, post_url, morechildren_ids, post_author, viewing_comment, user_preferences, last_known_depth) => { 3 | return new Promise((resolve, reject) => { 4 | (async () => { 5 | let comments_html 6 | function commentAuthor(comment, classlist, submitter, moderator) { 7 | let classes = classlist.join(' ') 8 | if (comment.author === '[deleted]') 9 | return `[deleted]` 10 | else 11 | return `${comment.author}${submitter || ''}${moderator || ''}` 12 | } 13 | 14 | if(!user_preferences) 15 | user_preferences = {} 16 | 17 | if(comments.author !== undefined && comments.body_html !== undefined) { 18 | let classlist = [] 19 | let submitter_link = '' 20 | let moderator = false 21 | let submitter = false 22 | let edited_span = '' 23 | let controversial_span = '' 24 | 25 | if(post_author === comments.author) { 26 | classlist.push('submitter') 27 | submitter_link = `[S]` 28 | submitter = true 29 | } 30 | if(comments.distinguished === 'moderator') { 31 | classlist.push('green') 32 | moderator_badge = ` [M]` 33 | moderator = true 34 | } 35 | if(comments.score_hidden) { 36 | ups = `[score hidden]` 37 | } else { 38 | ups = `${kFormatter(comments.ups)} points` 39 | } 40 | if(comments.edited) { 41 | edited_span = `*` 42 | } 43 | if(comments.controversiality > 0) { 44 | controversial_span = `` 45 | } 46 | comments_html = ` 47 |
48 |
0 && comments.depth < 2 ? '' : 'open'}> 49 | 50 |

${commentAuthor(comments, classlist, submitter && submitter_link, moderator && moderator_badge)}

51 |

${ups}

52 |

${timeDifference(comments.created)}${edited_span}

53 |

${comments.stickied ? 'stickied comment' : ''}

54 |
55 |
56 |

${commentAuthor(comments, classlist, submitter && submitter_link, moderator && moderator_badge)}

57 |

${comments.user_flair}

58 |

${ups}${controversial_span}

59 |

60 | ${timeDifference(comments.created)}${edited_span} 61 |

62 |

${comments.stickied ? 'stickied comment' : ''}

63 |
64 |
${unescape(comments.body_html)}
65 | ` 66 | } else { 67 | if(comments.children) { 68 | if(comments.children.length > 0) { 69 | let parent_id = comments.parent_id.split('_')[1] 70 | if(post_id === parent_id && !morechildren_ids) { 71 | let more_comments = [] 72 | if(comments.children.length > 100) { 73 | more_comments = comments.children.slice(0, 100) 74 | } else { 75 | more_comments = comments.children 76 | } 77 | 78 | comments_html = ` 79 |
80 | 81 | 82 |
83 | ` 84 | } else { 85 | let load_comms_href = parent_id 86 | if(!morechildren_ids) { 87 | comments_html = ` 88 | 91 | ` 92 | } else { 93 | if(next_comment === false) { 94 | let more_comments = morechildren_ids[morechildren_ids.length - 1].data.children 95 | if(more_comments.length > 100) { 96 | more_comments = more_comments.slice(0, 100) 97 | } else { 98 | more_comments = more_comments 99 | } 100 | comments_html = ` 101 |
102 | 103 | 104 |
105 | ` 106 | } else { 107 | comments_html = ` 108 | 111 | ` 112 | } 113 | 114 | } 115 | } 116 | } else { 117 | let link = comments.parent_id.split('_')[1] 118 | comments_html = ` 119 |
120 | continue this thread 121 |
122 | ` 123 | } 124 | } 125 | } 126 | 127 | if(morechildren_ids) { 128 | if(next_comment.depth != undefined) { 129 | if(next_comment.depth < last_known_depth) { 130 | let times = last_known_depth - next_comment.depth 131 | if(next_comment.depth == 0) { 132 | times = last_known_depth 133 | } 134 | for(var i = 0; i < times; i++) { 135 | comments_html += `
` 136 | } 137 | } 138 | } 139 | } 140 | 141 | if(comments.replies) { 142 | for(var i = 0; i < comments.replies.length; i++) { 143 | let comment = comments.replies[i] 144 | if(comment.type !== 'load_more') { 145 | let classlist = [] 146 | let submitter_link = '' 147 | let moderator = false 148 | let submitter = false 149 | let ups = '' 150 | let edited_span = '' 151 | let controversial_span = '' 152 | 153 | if(post_author === comment.author) { 154 | classlist.push('submitter') 155 | submitter_link = `[S]` 156 | submitter = true 157 | } 158 | if(comment.distinguished === 'moderator') { 159 | classlist.push('green') 160 | moderator_badge = ` [M]` 161 | moderator = true 162 | } 163 | if(comment.score_hidden) { 164 | ups = `[score hidden]` 165 | } else { 166 | ups = `${kFormatter(comment.ups)} points` 167 | } 168 | if(comment.edited) { 169 | edited_span = `*` 170 | } 171 | if(comment.controversiality > 0) { 172 | controversial_span = `` 173 | } 174 | comments_html += ` 175 |
176 |
177 | 178 |

${commentAuthor(comment, classlist, submitter && submitter_link, moderator && moderator_badge)}

179 |

${ups}

180 |

${timeDifference(comment.created)}${edited_span}

181 |

${comment.stickied ? 'stickied comment' : ''}

182 |
183 |
184 |

${commentAuthor(comment, classlist, submitter && submitter_link, moderator && moderator_badge)}

185 |

${comment.user_flair}

186 |

${ups}${controversial_span}

187 |

188 | ${timeDifference(comment.created)}${edited_span} 189 |

190 |

${comment.stickied ? 'stickied comment' : ''}

191 |
192 |
${unescape(comment.body_html)}
193 | ` 194 | let replies_html = '' 195 | if(comment.replies) { 196 | if(comment.replies.length) { 197 | for(var j = 0; j < comment.replies.length; j++) { 198 | let next_reply = false 199 | if(comment.replies[j+1]) { 200 | next_reply = comment.replies[j+1] 201 | } 202 | replies_html += await compilePostCommentsHtml(comment.replies[j], next_reply, post_id, post_url, null, post_author, viewing_comment, user_preferences) 203 | } 204 | } 205 | } 206 | comments_html += replies_html + '
' 207 | } else { 208 | if(comment.children.length > 0) { 209 | let parent_id = comment.parent_id.split('_')[1] 210 | let load_comms_href = parent_id 211 | 212 | comments_html += ` 213 | 216 | ` 217 | } else { 218 | let link = comment.parent_id.split('_')[1] 219 | comments_html = ` 220 |
221 | continue this thread 222 |
223 | ` 224 | } 225 | } 226 | } 227 | } 228 | 229 | let next_comment_parent_id = null 230 | if(next_comment) { 231 | if(next_comment.parent_id) { 232 | next_comment_parent_id = next_comment.parent_id.split('_')[1] 233 | } 234 | } 235 | 236 | if((comments.replies || comments.author !== undefined) && next_comment_parent_id !== comments.id) { 237 | comments_html += `` 238 | } 239 | next_comment_parent_id = null 240 | 241 | resolve(comments_html) 242 | })() 243 | }) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /inc/downloadAndSave.js: -------------------------------------------------------------------------------- 1 | module.exports = function(tools) { 2 | const config = require('../config') 3 | const {spawn} = require('child_process') 4 | const fs = require('fs') 5 | this.downloadAndSave = (url, file_prefix = '', gifmp4, isYouTubeThumbnail) => { 6 | /** 7 | * This function downloads media (video or image) to disk. 8 | * Returns a localized URL 9 | * 10 | * For example for images: 11 | * https://external-preview.redd.it/DiaeK_j5fqpBqbatvo7GZzbHNJY2oxEym93B_3.jpg 12 | * => 13 | * https://teddit.net/pics/DiaeK_j5fqpBqbatvo7GZzbHNJY2oxEym93B_3.jpg 14 | * 15 | * For videos: 16 | * https://v.redd.it/f3lcdk4ydcl51/DASH_480.mp4?source=fallback 17 | * => 18 | * https://teddit.net/vids/f3lcdk4ydcl51.mp4 19 | */ 20 | let valid_video_extensions = ['mp4', 'webm', 'ogg'] 21 | let invalid_urls = ['self', 'default', 'nsfw', 'image', 'spoiler', 'undefined', undefined, null, ''] 22 | return new Promise((resolve, reject) => { 23 | if(!invalid_urls.includes(url)) { 24 | (async () => { 25 | let temp_url = new URL(url) 26 | if(config.valid_media_domains.includes(temp_url.hostname)) { 27 | let pathname = temp_url.pathname 28 | let file_ext 29 | let has_extension = true 30 | let dir = '' 31 | 32 | if(gifmp4) { 33 | file_ext = 'mp4' 34 | } else { 35 | if (file_prefix === 'flair_') { 36 | // Flair emojis end in the name without a file extension 37 | file_ext = 'png' 38 | } else if(!pathname.includes('.')) { 39 | /** 40 | * Sometimes reddit API returns video without extension, like 41 | * "DASH_480" and not "DASH_480.mp4". 42 | */ 43 | file_ext = 'mp4' 44 | has_extension = false 45 | } else { 46 | file_ext = pathname.substring(pathname.lastIndexOf('.') + 1) 47 | } 48 | } 49 | 50 | if(file_prefix === 'thumb_') 51 | dir = 'thumbs/' 52 | if(file_prefix === 'flair_') 53 | dir = 'flairs/' 54 | if(file_prefix === 'icon_') 55 | dir = 'icons/' 56 | 57 | if(valid_video_extensions.includes(file_ext) || gifmp4) { 58 | /* Is video. */ 59 | if(!config.video_enabled) { 60 | resolve('') 61 | } else { 62 | let filename = `${temp_url.pathname.substr(1).split('/')[0]}.${file_ext}` 63 | if(temp_url.hostname === 'thumbs.gfycat.com') 64 | filename = `${temp_url.pathname.substr(1).split('/')[0]}` 65 | 66 | let path = `./static/vids/${dir}${filename}` 67 | let temp_path = `./static/vids/${dir}temp_${filename}` 68 | if(!fs.existsSync(path)) { 69 | const download = await downloadFile(cleanUrl(url)) 70 | if(download.success === true) { 71 | const write = await writeToDisk(download.data, temp_path) 72 | if(write.success === true) { 73 | let audio_url 74 | if(has_extension) { 75 | audio_url = `${url.split('_')[0]}_audio.mp4` 76 | } else { 77 | let ending = `${temp_url.pathname.split('/').slice(-1)[0]}` 78 | audio_url = url.replace(ending, 'audio') 79 | } 80 | const download_audio = await downloadFile(cleanUrl(audio_url)) 81 | if(download_audio.success === true) { 82 | let audio_path = `./static/vids/${dir}temp_audio_${filename}` 83 | const write_audio = await writeToDisk(download_audio.data, audio_path) 84 | if(write_audio.success === true) { 85 | let processVideo = spawn('ffmpeg', ['-y', '-i', temp_path, '-i', audio_path, '-c', 'copy', path]) 86 | processVideo.on('exit', (code) => { 87 | if(code === 0) { 88 | let final_url = `/vids/${dir}${filename}` 89 | let temp_files = [temp_path, audio_path] 90 | deleteFiles(temp_files, (error) => { 91 | if(error) { 92 | console.log(`Error while deleting temporary files:`, error) 93 | } 94 | }) 95 | resolve(final_url) 96 | } else { 97 | console.log(`ffmpeg error, exited with code: `, code) 98 | resolve('') 99 | } 100 | }) 101 | } else { 102 | console.log(`Error while writing temp audio file.`) 103 | resolve('') 104 | } 105 | } else { 106 | /** 107 | * Either the video doesn't have any audio track, or we 108 | * failed downloading it. Let's return the video only. 109 | */ 110 | fs.rename(temp_path, path, (error) => { 111 | if(error) { 112 | console.log(`Error while renaming the temp video file: ${temp_path} => ${path}.`, error) 113 | } else { 114 | let final_url = `/vids/${dir}${filename}` 115 | resolve(final_url) 116 | } 117 | }) 118 | } 119 | } else { 120 | console.log(`Error while writing video file.`) 121 | resolve('') 122 | } 123 | } else { 124 | console.log(`Error while downloading video file.`) 125 | resolve('') 126 | } 127 | } else { 128 | resolve(`/vids/${dir}${filename}`) 129 | } 130 | } 131 | } else { 132 | /* Is image. */ 133 | let path, youtubeThumbUrl, filename 134 | if(isYouTubeThumbnail) { 135 | filename = `${file_prefix}${temp_url.pathname.split('/').slice(-2)[0]}_hqdefault.jpg` 136 | } else { 137 | let width = '' 138 | if(temp_url.searchParams.get('width')) { 139 | width = temp_url.searchParams.get('width') 140 | } 141 | if(file_prefix === 'flair_') { 142 | // Flair emojis have a full path of `UUID/name`, 143 | // so we need to incorporate the UUID to avoid duplicates 144 | // since names alone are not unique across all of reddit 145 | filename = `${pathname.slice(1).replace('/', '_')}.png` // Only first replacement is fine 146 | } else { 147 | filename = `${file_prefix}w:${temp_url.searchParams.get('width')}_${temp_url.pathname.split('/').slice(-1)}` 148 | } 149 | } 150 | path = `./static/pics/${dir}${filename}` 151 | if(!fs.existsSync(path)) { 152 | const download = await downloadFile(cleanUrl(url)) 153 | if(download.success === true) { 154 | const write = await writeToDisk(download.data, path) 155 | if(write.success === true) { 156 | let final_url = `/pics/${dir}${filename}` 157 | resolve(final_url) 158 | } else { 159 | console.log(`Error while writing image file.`, write) 160 | resolve('') 161 | } 162 | } else { 163 | console.log(`Error while downloading image file.`) 164 | resolve('') 165 | } 166 | } else { 167 | resolve(`/pics/${dir}${filename}`) 168 | } 169 | } 170 | } else { 171 | console.log(`Invalid URL for downloading media: ${temp_url.hostname}.`) 172 | resolve('') 173 | } 174 | })() 175 | } else { 176 | resolve('self') 177 | } 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /inc/initRedditApi.js: -------------------------------------------------------------------------------- 1 | module.exports = function(fetch) { 2 | const config = require('../config'); 3 | this.initRedditApi = function() { 4 | if(!config.use_reddit_oauth) 5 | return null 6 | 7 | let options = { 8 | body: `grant_type=https://oauth.reddit.com/grants/installed_client&device_id=DO_NOT_TRACK_THIS_DEVICE&duration=permanent`, 9 | headers: { 10 | 'Content-Type': 'application/x-www-form-urlencoded', 11 | 'Authorization': `Basic ${client_id_b64}`, 12 | }, 13 | method: 'POST' 14 | } 15 | 16 | fetch('https://www.reddit.com/api/v1/access_token', options) 17 | .then(result => { 18 | if(result.status === 200) { 19 | result.json() 20 | .then(data => { 21 | //console.log(data) 22 | if(data.access_token) { 23 | reddit_access_token = data.access_token 24 | reddit_refresh_token = data.refresh_token 25 | console.log(`Successfully obtained a reddit API key.`) 26 | } else { 27 | console.log(`Error while obtaining a reddit API key. Check that your reddit app ID is correct. Reddit could also be down.`, data) 28 | } 29 | }) 30 | } else { 31 | console.error(`Something went wrong while trying to get an access token from reddit API. ${result.status} – ${result.statusText}`) 32 | console.error(reddit_api_error_text) 33 | return res.render('index', { json: null, http_status_code: result.status }) 34 | } 35 | }).catch(error => { 36 | console.log(`Error while obtaining a reddit API key.`, error) 37 | }) 38 | 39 | setInterval(() => { 40 | refreshRedditToken() 41 | }, 1000 * 60 * 58) /* Refresh access token every ~1 hour. */ 42 | } 43 | 44 | this.refreshRedditToken = function() { 45 | let options = { 46 | body: `grant_type=refresh_token&refresh_token=${reddit_refresh_token}`, 47 | headers: { 48 | 'Content-Type': 'application/x-www-form-urlencoded', 49 | 'Authorization': `Basic ${client_id_b64}`, 50 | }, 51 | method: 'POST' 52 | } 53 | fetch('https://www.reddit.com/api/v1/access_token', options) 54 | .then(result => { 55 | if(result.status === 200) { 56 | result.json() 57 | .then(data => { 58 | //console.log(data) 59 | if(data.access_token) { 60 | reddit_access_token = data.access_token 61 | console.log(`Successfully refreshed the reddit API key.`) 62 | } else { 63 | console.log(`Error while refreshing the reddit API key.`, data) 64 | } 65 | }) 66 | } else { 67 | console.error(`Something went wrong while fetching data from reddit API. ${result.status} – ${result.statusText}`) 68 | console.error(reddit_api_error_text) 69 | return res.render('index', { json: null, http_status_code: result.status }) 70 | } 71 | }).catch(error => { 72 | console.log(`Error while refreshing the reddit API key.`, error) 73 | }) 74 | } 75 | this.redditApiGETHeaders = function() { 76 | if(!config.use_reddit_oauth) 77 | return { method: 'GET' } 78 | 79 | return { 80 | headers: { 81 | Authorization: `Bearer ${reddit_access_token}` 82 | }, 83 | method: 'GET' 84 | } 85 | } 86 | initRedditApi() 87 | } 88 | -------------------------------------------------------------------------------- /inc/processJsonPost.js: -------------------------------------------------------------------------------- 1 | module.exports = function(fetch) { 2 | var compilePostComments = require('./compilePostComments.js')(); 3 | var procPostMedia = require('./processPostMedia.js')(); 4 | this.processJsonPost = (json, parsed, user_preferences) => { 5 | return new Promise(resolve => { 6 | (async () => { 7 | if(!parsed) { 8 | json = JSON.parse(json) 9 | } 10 | 11 | let post = json[0].data.children[0].data 12 | let post_id = post.name 13 | let comments = json[1].data.children 14 | 15 | let obj = { 16 | author: post.author, 17 | created: post.created_utc, 18 | edited: post.edited, 19 | is_video: post.is_video, 20 | locked: post.locked, 21 | link_flair_text: post.link_flair_text, 22 | name: post_id, 23 | num_comments: post.num_comments, 24 | over_18: post.over_18, 25 | permalink: teddifyUrl(post.permalink), 26 | title: post.title, 27 | url: teddifyUrl(post.url, user_preferences), 28 | ups: post.ups, 29 | id: post.id, 30 | domain: post.domain, 31 | contest_mode: post.contest_mode, 32 | upvote_ratio: post.upvote_ratio, 33 | comments: null, 34 | has_media: false, 35 | media: null, 36 | images: null, 37 | crosspost: false, 38 | selftext: unescape(post.selftext_html), 39 | poll_data: post.poll_data, 40 | link_flair: (user_preferences.flairs != 'false' ? await formatLinkFlair(post) : ''), 41 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(post) : '') 42 | } 43 | 44 | let valid_embed_video_domains = ['gfycat.com'] 45 | let has_gif = false 46 | let gif_to_mp4 = null 47 | let reddit_video = null 48 | let embed_video = false 49 | 50 | if(post.media) 51 | if(valid_embed_video_domains.includes(post.media.type)) 52 | embed_video = true 53 | 54 | if(post.preview && !embed_video) { 55 | if(post.preview.reddit_video_preview) { 56 | if(post.preview.reddit_video_preview.is_gif) { 57 | has_gif = true 58 | gif_url = post.preview.reddit_video_preview.fallback_url 59 | } else { 60 | let file_ext = getFileExtension(post.preview.reddit_video_preview.fallback_url) 61 | if(file_ext === 'mp4')  { 62 | post.media = true 63 | reddit_video = post.preview.reddit_video_preview 64 | } 65 | } 66 | } 67 | if(post.preview.images) { 68 | if(post.preview.images[0].source) { 69 | let file_ext = getFileExtension(post.preview.images[0].source.url) 70 | if(file_ext === 'gif') { 71 | has_gif = true 72 | let resolutions = post.preview.images[0].variants.mp4.resolutions 73 | gif_to_mp4 = resolutions[resolutions.length - 1] 74 | } 75 | } 76 | } 77 | } 78 | 79 | obj = await processPostMedia(obj, post, post.media, has_gif, reddit_video, gif_to_mp4) 80 | 81 | if(post.crosspost_parent_list) { 82 | post.crosspost = post.crosspost_parent_list[0] 83 | } 84 | if(post.crosspost) { 85 | obj = await processPostMedia(obj, post.crosspost, post.crosspost.media, has_gif, reddit_video, gif_to_mp4) 86 | obj.crosspost = { 87 | author: post.crosspost.author, 88 | created: post.crosspost.created_utc, 89 | subreddit: post.crosspost.subreddit, 90 | title: post.crosspost.title, 91 | name: post.crosspost.name, 92 | num_comments: post.crosspost.num_comments, 93 | over_18: post.crosspost.over_18, 94 | id: post.crosspost.id, 95 | permalink: teddifyUrl(post.crosspost.permalink), 96 | ups: post.crosspost.ups, 97 | selftext: unescape(post.selftext_html), 98 | selftext_crosspost: unescape(post.crosspost.selftext_html), 99 | poll_data: post.poll_data, 100 | is_crosspost: true, 101 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(post) : '') 102 | } 103 | } 104 | 105 | if(post.preview && !obj.has_media) { 106 | obj.images = { 107 | source: await downloadAndSave(post.preview.images[0].source.url) 108 | } 109 | } 110 | 111 | if(obj.media) { 112 | if(obj.media.source === 'external') { 113 | if(post.preview) { 114 | obj.images = { 115 | source: await downloadAndSave(post.preview.images[0].source.url) 116 | } 117 | } 118 | } 119 | } 120 | 121 | if(post.gallery_data) { 122 | obj.gallery = true 123 | obj.gallery_items = [] 124 | for(var i = 0; i < post.gallery_data.items.length; i++) { 125 | let id = post.gallery_data.items[i].media_id 126 | if(post.media_metadata[id]) { 127 | if(post.media_metadata[id].p) { 128 | if(post.media_metadata[id].p[0]) { 129 | let item = { source: null, thumbnail: null, large: null } 130 | if(post.media_metadata[id].s && post.media_metadata[id].p[0].u) { 131 | item = { 132 | type: post.media_metadata[id].e, 133 | source: await downloadAndSave(post.media_metadata[id].s.u), 134 | thumbnail: await downloadAndSave(post.media_metadata[id].p[0].u), 135 | large: await downloadAndSave(post.media_metadata[id].p[post.media_metadata[id].p.length - 1].u), 136 | } 137 | } 138 | obj.gallery_items.push(item) 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | let comms = [] 146 | for(var i = 0; i < comments.length; i++) { 147 | let comment = comments[i].data 148 | let kind = comments[i].kind 149 | let obj = {} 150 | 151 | if(kind !== 'more') { 152 | obj = { 153 | author: comment.author, 154 | body_html: comment.body_html, 155 | parent_id: comment.parent_id, 156 | created: comment.created_utc, 157 | edited: comment.edited, 158 | score: comment.score, 159 | ups: comment.ups, 160 | id: comment.id, 161 | permalink: teddifyUrl(comment.permalink), 162 | stickied: comment.stickied, 163 | distinguished: comment.distinguished, 164 | score_hidden: comment.score_hidden, 165 | edited: comment.edited, 166 | replies: [], 167 | depth: comment.depth, 168 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(comment) : ''), 169 | controversiality: (user_preferences.highlight_controversial != 'false' ? comment.controversiality : '') 170 | } 171 | } else { 172 | obj = { 173 | type: 'load_more', 174 | count: comment.count, 175 | id: comment.id, 176 | parent_id: comment.parent_id, 177 | post_id: post.name, 178 | children: [] 179 | } 180 | } 181 | 182 | if(comment.replies && kind !== 'more') { 183 | if(comment.replies.data) { 184 | if(comment.replies.data.children.length > 0) { 185 | obj.replies = await processReplies(comment.replies.data.children, post_id, 1, user_preferences) 186 | } 187 | } 188 | } 189 | 190 | if(comment.children) { 191 | for(var j = 0; j < comment.children.length; j++) { 192 | obj.children.push(comment.children[j]) 193 | } 194 | } 195 | 196 | comms.push(obj) 197 | } 198 | 199 | obj.comments = comms 200 | 201 | resolve(obj) 202 | })() 203 | }) 204 | } 205 | 206 | this.finalizeJsonPost = async (processed_json, post_id, post_url, morechildren_ids, viewing_comment, user_preferences) => { 207 | let comments_html = `
` 208 | let comments = processed_json.comments 209 | let last_known_depth = undefined 210 | for(var i = 0; i < comments.length; i++) { 211 | let next_comment = false 212 | if(comments[i+1]) { 213 | next_comment = comments[i+1] 214 | } 215 | if(comments[i].depth != undefined) { 216 | last_known_depth = comments[i].depth 217 | } 218 | 219 | comments_html += await compilePostCommentsHtml(comments[i], next_comment, post_id, post_url, morechildren_ids, processed_json.author, viewing_comment, user_preferences, last_known_depth) 220 | } 221 | 222 | comments_html += `
` 223 | 224 | delete processed_json['comments'] 225 | let post_data = processed_json 226 | return { post_data: post_data, comments: comments_html } 227 | } 228 | 229 | this.processReplies = async (data, post_id, depth, user_preferences) => { 230 | let return_replies = [] 231 | for(var i = 0; i < data.length; i++) { 232 | let kind = data[i].kind 233 | let reply = data[i].data 234 | let obj = {} 235 | if(kind !== 'more') { 236 | obj = { 237 | author: reply.author, 238 | body_html: reply.body_html, 239 | parent_id: reply.parent_id, 240 | created: reply.created_utc, 241 | edited: reply.edited, 242 | score: reply.score, 243 | ups: reply.ups, 244 | id: reply.id, 245 | permalink: teddifyUrl(reply.permalink), 246 | stickied: reply.stickied, 247 | distinguished: reply.distinguished, 248 | score_hidden: reply.score_hidden, 249 | edited: reply.edited, 250 | replies: [], 251 | depth: depth, 252 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(reply) : ''), 253 | controversiality: (user_preferences.highlight_controversial != 'false' ? reply.controversiality : '') 254 | } 255 | } else { 256 | obj = { 257 | type: 'load_more', 258 | count: reply.count, 259 | id: reply.id, 260 | parent_id: reply.parent_id, 261 | post_id: post_id, 262 | children: [], 263 | depth: depth 264 | } 265 | } 266 | 267 | if(reply.replies && kind !== 'more') { 268 | if(reply.replies.data.children.length) { 269 | for(var j = 0; j < reply.replies.data.children.length; j++) { 270 | let comment = reply.replies.data.children[j].data 271 | let objct = {} 272 | 273 | if(comment.author && comment.body_html) { 274 | objct = { 275 | author: comment.author, 276 | body_html: comment.body_html, 277 | parent_id: comment.parent_id, 278 | created: comment.created_utc, 279 | edited: comment.edited, 280 | score: comment.score, 281 | ups: comment.ups, 282 | id: comment.id, 283 | permalink: teddifyUrl(comment.permalink), 284 | score_hidden: comment.score_hidden, 285 | distinguished: comment.distinguished, 286 | distinguished: comment.edited, 287 | replies: [], 288 | depth: depth + 1, 289 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(comment) : ''), 290 | controversiality: (user_preferences.highlight_controversial != 'false' ? comment.controversiality : '') 291 | } 292 | } else { 293 | objct = { 294 | type: 'load_more', 295 | count: comment.count, 296 | id: comment.id, 297 | parent_id: comment.parent_id, 298 | post_id: post_id, 299 | children: [], 300 | depth: depth + 1 301 | } 302 | if(comment.children) { 303 | for(var k = 0; k < comment.children.length; k++) { 304 | objct.children.push(comment.children[k]) 305 | } 306 | } 307 | } 308 | 309 | if(comment.replies) { 310 | if(comment.replies.data) { 311 | if(comment.replies.data.children.length > 0) { 312 | objct.replies = await processReplies(comment.replies.data.children, post_id, depth, user_preferences) 313 | } 314 | } 315 | } 316 | 317 | obj.replies.push(objct) 318 | } 319 | } 320 | } 321 | 322 | if(reply.children) { 323 | for(var j = 0; j < reply.children.length; j++) { 324 | obj.children.push(reply.children[j]) 325 | } 326 | } 327 | 328 | return_replies.push(obj) 329 | } 330 | return return_replies 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /inc/processJsonSubreddit.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config'); 3 | this.processJsonSubreddit = (json, from, subreddit_front, user_preferences, saved) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(from === 'redis') { 7 | json = JSON.parse(json) 8 | } 9 | if(json.error) { 10 | resolve({ error: true, error_data: json }) 11 | } else { 12 | if(saved) { 13 | let t = { 14 | data: { 15 | before: null, 16 | after: null, 17 | children: json 18 | } 19 | } 20 | json = t 21 | } 22 | 23 | let before = json.data.before 24 | let after = json.data.after 25 | 26 | let ret = { 27 | info: { 28 | before: before, 29 | after: after 30 | }, 31 | links: [] 32 | } 33 | 34 | let children_len = json.data.children.length 35 | 36 | for(var i = 0; i < children_len; i++) { 37 | let data = json.data.children[i].data 38 | let images = null 39 | let is_self_link = false 40 | let valid_reddit_self_domains = ['reddit.com'] 41 | 42 | if(data.over_18) 43 | if((config.nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false') 44 | continue 45 | 46 | if(data.domain) { 47 | let tld = data.domain.split('self.') 48 | if(tld.length > 1) { 49 | if(!tld[1].includes('.')) { 50 | is_self_link = true 51 | } 52 | } 53 | if(config.valid_media_domains.includes(data.domain) || valid_reddit_self_domains.includes(data.domain)) { 54 | is_self_link = true 55 | } 56 | } 57 | 58 | if(data.preview && data.thumbnail !== 'self') { 59 | if(!data.url.startsWith('/r/') && isGif(data.url)) { 60 | images = { 61 | thumb: await downloadAndSave(data.thumbnail, 'thumb_') 62 | } 63 | } else { 64 | if(data.preview.images[0].resolutions[0]) { 65 | let preview = null 66 | if(!isGif(data.url) && !data.post_hint.includes(':video')) 67 | preview = await downloadAndSave(data.preview.images[0].source.url) 68 | images = { 69 | thumb: await downloadAndSave(data.preview.images[0].resolutions[0].url, 'thumb_'), 70 | preview: preview 71 | } 72 | } 73 | } 74 | } 75 | let obj = { 76 | author: data.author, 77 | created: data.created_utc, 78 | domain: data.domain, 79 | id: data.id, 80 | images: images, 81 | is_video: data.is_video, 82 | link_flair_text: data.link_flair_text, 83 | locked: data.locked, 84 | media: data.media, 85 | selftext_html: data.selftext_html, 86 | num_comments: data.num_comments, 87 | over_18: data.over_18, 88 | permalink: data.permalink, 89 | score: data.score, 90 | subreddit: data.subreddit, 91 | title: data.title, 92 | ups: data.ups, 93 | upvote_ratio: data.upvote_ratio, 94 | url: replaceDomains(data.url, user_preferences), 95 | stickied: data.stickied, 96 | is_self_link: is_self_link, 97 | subreddit_front: subreddit_front, 98 | link_flair: (user_preferences.flairs != 'false' ? await formatLinkFlair(data) : ''), 99 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(data) : '') 100 | } 101 | ret.links.push(obj) 102 | } 103 | resolve(ret) 104 | } 105 | })() 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /inc/processJsonUser.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config'); 3 | this.processJsonUser = function(json, parsed, after, before, user_preferences, kind, post_type) { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(!parsed) { 7 | json = JSON.parse(json) 8 | } 9 | 10 | let about = json.about.data 11 | let posts = [] 12 | let view_more_posts = false 13 | let posts_limit = 25 14 | let user_front = false 15 | 16 | if(json.overview.data.children.length > posts_limit) { 17 | view_more_posts = true 18 | } else { 19 | posts_limit = json.overview.data.children.length 20 | } 21 | 22 | if(!after && !before) { 23 | user_front = true 24 | } 25 | 26 | if(json.overview.data.children) { 27 | if(json.overview.data.children[posts_limit - 1]) { 28 | after = json.overview.data.children[posts_limit - 1].data.name 29 | } 30 | if(json.overview.data.children[0]) { 31 | before = json.overview.data.children[0].data.name 32 | } 33 | } 34 | 35 | for(var i = 0; i < posts_limit; i++) { 36 | let post = json.overview.data.children[i].data 37 | let thumbnail = 'self' 38 | let type = json.overview.data.children[i].kind 39 | let obj 40 | 41 | let post_id = post.permalink.split('/').slice(-2)[0] + '/' 42 | let url = post.permalink.replace(post_id, '') 43 | 44 | if(type !== kind && kind) 45 | continue 46 | 47 | if(post.over_18) 48 | if((config.nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false') 49 | continue 50 | 51 | if(type === 't3') { 52 | let duration = null 53 | if(post.media) { 54 | if(post.is_video) { 55 | if(post.media.reddit_video) { 56 | duration = post.media.reddit_video.duration 57 | } 58 | } 59 | } 60 | 61 | obj = { 62 | type: type, 63 | subreddit: post.subreddit, 64 | title: post.title, 65 | created: post.created_utc, 66 | ups: post.ups, 67 | url: replaceDomains(url, user_preferences), 68 | thumbnail: await downloadAndSave(post.thumbnail), 69 | duration: duration, 70 | edited: post.edited, 71 | selftext_html: unescape(post.selftext_html), 72 | num_comments: post.num_comments, 73 | over_18: post.over_18, 74 | permalink: post.permalink, 75 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(post) : '') 76 | } 77 | } 78 | if(type === 't1') { 79 | obj = { 80 | type: type, 81 | subreddit: post.subreddit, 82 | title: post.title, 83 | created: post.created_utc, 84 | subreddit_name_prefixed: post.subreddit_name_prefixed, 85 | ups: post.ups, 86 | url: replaceDomains(url, user_preferences), 87 | edited: post.edited, 88 | body_html: unescape(post.body_html), 89 | num_comments: post.num_comments, 90 | over_18: post.over_18, 91 | permalink: post.permalink, 92 | link_author: post.link_author, 93 | link_title: post.link_title, 94 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(post) : '') 95 | } 96 | } 97 | posts.push(obj) 98 | } 99 | 100 | let obj = { 101 | username: about.name, 102 | icon_img: await downloadAndSave(about.icon_img, "icon_"), 103 | created: about.created_utc, 104 | verified: about.verified, 105 | link_karma: about.link_karma, 106 | comment_karma: about.comment_karma, 107 | view_more_posts: view_more_posts, 108 | user_front: user_front, 109 | post_type: post_type, 110 | before: before, 111 | after: after, 112 | posts: posts 113 | } 114 | 115 | resolve(obj) 116 | })() 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /inc/processMoreComments.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config') 3 | this.moreComments = (fetch, redis, post_url, comment_ids, id) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | let key = `${post_url}:morechildren:comment_ids:${comment_ids}` 7 | redis.get(key, (error, json) => { 8 | if(error) { 9 | console.error(`Error getting the ${key} key from redis (moreComments()).`, error) 10 | resolve(false) 11 | } 12 | if(json) { 13 | json = JSON.parse(json) 14 | resolve(json) 15 | } else { 16 | let url = `https://oauth.reddit.com/api/morechildren?api_type=json&children=${comment_ids}&limit_children=false&link_id=t3_${id}` 17 | fetch(encodeURI(url), redditApiGETHeaders()) 18 | .then(result => { 19 | if(result.status === 200) { 20 | result.json() 21 | .then(json => { 22 | if(json.json.data) { 23 | if(json.json.data.things) { 24 | let comments = json.json.data.things 25 | redis.setex(key, config.setexs.posts, JSON.stringify(comments), (error) => { 26 | if(error) { 27 | console.error(`Error setting the ${key} key to redis (moreComments()).`, error) 28 | resolve(false) 29 | } else { 30 | console.log(`Fetched the JSON from Reddit (endpoint "morechildren") for URL: ${post_url}. (moreComments())`) 31 | resolve(comments) 32 | } 33 | }) 34 | } else { 35 | resolve(false) 36 | } 37 | } else { 38 | resolve(false) 39 | } 40 | }) 41 | } else { 42 | console.error(`Something went wrong while fetching data from Reddit. ${result.status} – ${result.statusText} (moreComments())`) 43 | resolve(false) 44 | } 45 | }).catch(error => { 46 | console.log(`Error fetching the JSON from Reddit (endpoint "morechildren") with url: ${url}. (moreComments())`, error) 47 | resolve(false) 48 | }) 49 | } 50 | }) 51 | })() 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /inc/processPostMedia.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config') 3 | this.processPostMedia = (obj, post, post_media, has_gif, reddit_video, gif_to_mp4, user_preferences) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(post_media || has_gif) { 7 | if(!has_gif) { 8 | if(config.valid_embed_video_domains.includes(post_media.type)) { 9 | if(post_media.type === 'gfycat.com') { 10 | obj.has_media = true 11 | let video_url = post_media.oembed.thumbnail_url 12 | video_url = video_url.replace('size_restricted.gif', 'mobile.mp4') 13 | obj.media = { 14 | source: await downloadAndSave(video_url), 15 | height: post_media.oembed.thumbnail_height, 16 | width: post_media.oembed.thumbnail_width 17 | } 18 | } 19 | if(post_media.type === 'youtube.com') { 20 | obj.has_media = true 21 | obj.media = { 22 | source: 'YouTube', 23 | height: post_media.oembed.thumbnail_height, 24 | width: post_media.oembed.thumbnail_width, 25 | thumbnail: await downloadAndSave(post_media.oembed.thumbnail_url, '', false, true), 26 | author_name: post_media.oembed.author_name, 27 | author_url: replaceDomains(post_media.oembed.author_url, user_preferences), 28 | title: post_media.oembed.title, 29 | duration: null, 30 | is_gif: null, 31 | not_hosted_in_reddit: true, 32 | embed_src: null 33 | } 34 | 35 | try { 36 | let str = post_media.oembed.html 37 | let r = /iframe.*?src=\"(.*?)\"/; 38 | let src = r.exec(str)[1] 39 | let youtube_id = src.split('/embed/')[1].split('?')[0] 40 | let youtube_url = `https://youtube.com/watch?v=${youtube_id}` 41 | obj.media.embed_src = replaceDomains(youtube_url, user_preferences) 42 | } catch(error) { 43 | console.error(`Error while trying to get src link from embed youtube html.`, error) 44 | } 45 | } 46 | } else { 47 | obj.has_media = true 48 | let video 49 | if(!reddit_video) { 50 | video = post_media.reddit_video 51 | } else { 52 | video = post.preview.reddit_video_preview 53 | } 54 | 55 | if(video) { 56 | obj.media = { 57 | source: await downloadAndSave(video.fallback_url), 58 | height: video.height, 59 | width: video.width, 60 | duration: video.duration, 61 | is_gif: post_media.reddit_video.is_gif 62 | } 63 | } else { 64 | if(post_media.oembed) { 65 | obj.media = { 66 | source: 'external', 67 | height: post_media.oembed.height, 68 | width: post_media.oembed.width, 69 | provider_url: replaceDomains(post_media.oembed.provider_url, user_preferences), 70 | provider_name: post_media.oembed.provider_name, 71 | title: post_media.oembed.title, 72 | duration: null, 73 | is_gif: null, 74 | not_hosted_in_reddit: true, 75 | embed_src: null 76 | } 77 | try { 78 | let str = post_media.oembed.html 79 | let r = /iframe.*?src=\"(.*?)\"/; 80 | let src = r.exec(str)[1] 81 | obj.media.embed_src = replaceDomains(cleanUrl(src), user_preferences) 82 | } catch(error) { 83 | //console.error(`Error while trying to get src link from embed html.`, error) 84 | } 85 | if(!obj.media.embed_src) { 86 | obj.media.embed_src = replaceDomains(post_media.oembed.url, user_preferences) 87 | } 88 | } 89 | } 90 | } 91 | } else { 92 | obj.has_media = true 93 | if(!gif_to_mp4) { 94 | if(post.preview) { 95 | obj.media = { 96 | source: await downloadAndSave(post.preview.reddit_video_preview.fallback_url), 97 | height: post.preview.reddit_video_preview.height, 98 | width: post.preview.reddit_video_preview.width, 99 | duration: post.preview.reddit_video_preview.duration, 100 | is_gif: true 101 | } 102 | } else { 103 | obj.has_media = false 104 | } 105 | } else { 106 | obj.media = { 107 | source: await downloadAndSave(gif_to_mp4.url, null, true), 108 | height: gif_to_mp4.height, 109 | width: gif_to_mp4.width, 110 | duration: null, 111 | is_gif: null 112 | } 113 | } 114 | } 115 | } else { 116 | /** 117 | * Sometimes post has an image, but all the common keys which are implying 118 | * that the post has an iamge, are null or don't exist. Awesome Reddit! 119 | */ 120 | if(!post_media && !has_gif && !post.gallery_data && post.url != '') { 121 | try { 122 | let u = new URL(post.url) 123 | if(config.valid_media_domains.includes(u.hostname)) { 124 | let ext = u.pathname.split('.')[1] 125 | if(ext === 'jpg' || ext === 'png') { 126 | obj.images = { 127 | source: await downloadAndSave(post.url) 128 | } 129 | } 130 | } 131 | } catch(error) { 132 | //console.error(Invalid URL supplied when trying to fetch an image', error) 133 | } 134 | } 135 | } 136 | resolve(obj) 137 | })() 138 | }) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /inc/processSearchResults.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config'); 3 | this.processSearchResults = (json, parsed, after, before, user_preferences) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(!parsed) { 7 | json = JSON.parse(json) 8 | } 9 | let posts = [] 10 | let search_firstpage = false 11 | let before = json.data.before 12 | let after = json.data.after 13 | 14 | if(!after && !before) { 15 | search_firstpage = true 16 | } 17 | 18 | let suggested_subreddits = false 19 | if(json.suggested_subreddits) { 20 | if(json.suggested_subreddits.data) { 21 | if(json.suggested_subreddits.data.children.length > 0) { 22 | suggested_subreddits = json.suggested_subreddits.data.children 23 | } 24 | } 25 | } 26 | 27 | if(json.data.children) { 28 | let view_more_posts = false 29 | let posts_limit = 25 30 | 31 | if(json.data.children.length > posts_limit) { 32 | view_more_posts = true 33 | } else { 34 | posts_limit = json.data.children.length 35 | } 36 | 37 | for(var i = 0; i < posts_limit; i++) { 38 | let post = json.data.children[i].data 39 | let images = null 40 | let is_self_link = false 41 | let valid_reddit_self_domains = ['reddit.com'] 42 | 43 | if(post.over_18) 44 | if((config.nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false') 45 | continue 46 | 47 | if(post.domain) { 48 | let tld = post.domain.split('self.') 49 | if(tld.length > 1) { 50 | if(!tld[1].includes('.')) { 51 | is_self_link = true 52 | } 53 | } 54 | if(config.valid_media_domains.includes(post.domain) || valid_reddit_self_domains.includes(post.domain)) { 55 | is_self_link = true 56 | } 57 | } 58 | 59 | if(post.preview && post.thumbnail !== 'self') { 60 | if(!post.url.startsWith('/r/') && isGif(post.url)) { 61 | images = { 62 | thumb: await downloadAndSave(post.thumbnail, 'thumb_') 63 | } 64 | } else { 65 | if(post.preview.images[0].resolutions[0]) { 66 | images = { 67 | thumb: await downloadAndSave(post.preview.images[0].resolutions[0].url, 'thumb_') 68 | } 69 | } 70 | } 71 | } 72 | 73 | let obj = { 74 | subreddit: post.subreddit, 75 | title: post.title, 76 | created: post.created_utc, 77 | domain: post.domain, 78 | subreddit_name_prefixed: post.subreddit_name_prefixed, 79 | link_flair_text: post.link_flair_text, 80 | ups: post.ups, 81 | images: images, 82 | url: replaceDomains(post.url, user_preferences), 83 | edited: post.edited, 84 | selftext_html: unescape(post.body_html), 85 | num_comments: post.num_comments, 86 | over_18: post.over_18, 87 | permalink: post.permalink, 88 | is_self_link: is_self_link, 89 | author: post.author, 90 | link_title: post.link_title, 91 | link_flair: (user_preferences.flairs != 'false' ? await formatLinkFlair(post) : ''), 92 | user_flair: (user_preferences.flairs != 'false' ? await formatUserFlair(post) : '') 93 | } 94 | posts.push(obj) 95 | } 96 | } 97 | 98 | let obj = { 99 | search_firstpage: search_firstpage, 100 | before: before, 101 | after: after, 102 | posts: posts, 103 | suggested_subreddits: suggested_subreddits, 104 | } 105 | 106 | resolve(obj) 107 | })() 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /inc/processSubredditAbout.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config') 3 | this.processSubredditAbout = (subreddit, redis, fetch, RedditAPI) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(subreddit && !subreddit.includes('+') && subreddit !== 'all') { 7 | function returnRelevantKeys(json) { 8 | return { 9 | title: json.data.title, 10 | public_description_html: json.data.public_description_html, 11 | active_user_count: json.data.active_user_count, 12 | subscribers: json.data.subscribers, 13 | created_utc: json.data.created_utc, 14 | over18: json.data.over18, 15 | description_html: json.data.description_html, 16 | moderators: json.moderators 17 | } 18 | } 19 | 20 | let key = `${subreddit}:sidebar` 21 | redis.get(key, (error, json) => { 22 | if(error) { 23 | console.error(`Error getting the ${subreddit}:sidebar key from redis.`, error) 24 | resolve(null) 25 | } 26 | if(json) { 27 | json = JSON.parse(json) 28 | resolve(returnRelevantKeys(json)) 29 | } else { 30 | let url = `https://reddit.com/r/${subreddit}/about.json` 31 | if(config.use_reddit_oauth) { 32 | url = `https://oauth.reddit.com/r/${subreddit}/about` 33 | } 34 | fetch(encodeURI(url), redditApiGETHeaders()) 35 | .then(result => { 36 | if(result.status === 200) { 37 | result.json() 38 | .then(json => { 39 | json.moderators = [] 40 | redis.setex(key, config.setexs.sidebar, JSON.stringify(json), (error) => { 41 | if(error) { 42 | console.error('Error setting the sidebar key to redis.', error) 43 | return res.render('index', { json: null, user_preferences: req.cookies }) 44 | } else { 45 | console.log('Fetched the sidebar from reddit API.') 46 | let moderators_url = `https://reddit.com/r/${subreddit}/about/moderators.json` 47 | if(config.use_reddit_oauth) { 48 | moderators_url = `https://oauth.reddit.com/r/${subreddit}/about/moderators` 49 | } 50 | fetch(encodeURI(moderators_url), redditApiGETHeaders()) 51 | .then(mod_result => { 52 | if(mod_result.status === 200) { 53 | mod_result.json() 54 | .then(mod_json => { 55 | json.moderators = mod_json 56 | redis.setex(key, config.setexs.sidebar, JSON.stringify(json), (error) => { 57 | if(error) { 58 | console.error('Error setting the sidebar with moderators key to redis.', error) 59 | return res.render('index', { json: null, user_preferences: req.cookies }) 60 | } else { 61 | console.log('Fetched the moderators from reddit API.') 62 | resolve(returnRelevantKeys(json)) 63 | } 64 | }) 65 | }) 66 | } else { 67 | console.error(`Something went wrong while fetching moderators data from reddit API. ${mod_result.status} – ${mod_result.statusText}`) 68 | console.error(config.reddit_api_error_text) 69 | resolve(returnRelevantKeys(json)) 70 | } 71 | }).catch(error => { 72 | console.error('Error fetching moderators.', error) 73 | resolve(returnRelevantKeys(json)) 74 | }) 75 | } 76 | }) 77 | }) 78 | } else { 79 | console.error(`Something went wrong while fetching data from reddit API. ${result.status} – ${result.statusText}`) 80 | console.error(config.reddit_api_error_text) 81 | resolve(null) 82 | } 83 | }).catch(error => { 84 | console.error('Error fetching the sidebar.', error) 85 | resolve(null) 86 | }) 87 | } 88 | }) 89 | } else { 90 | resolve(null) 91 | } 92 | })() 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /inc/processSubredditsExplore.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../config'); 3 | this.processJsonSubredditsExplore = (json, from, subreddit_front, user_preferences) => { 4 | return new Promise(resolve => { 5 | (async () => { 6 | if(from === 'redis') { 7 | json = JSON.parse(json) 8 | } 9 | if(json.error) { 10 | resolve({ error: true, error_data: json }) 11 | } else { 12 | let before = json.data.before 13 | let after = json.data.after 14 | 15 | let ret = { 16 | info: { 17 | before: before, 18 | after: after 19 | }, 20 | links: [] 21 | } 22 | 23 | let children_len = json.data.children.length 24 | 25 | for(var i = 0; i < children_len; i++) { 26 | let data = json.data.children[i].data 27 | 28 | if(data.over_18) 29 | if((config.nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false') 30 | continue 31 | 32 | let obj = { 33 | created: data.created_utc, 34 | id: data.id, 35 | over_18: data.over_18, 36 | display_name: data.display_name, 37 | display_name_prefixed: data.display_name_prefixed, 38 | public_description: data.public_description, 39 | url: replaceDomains(data.url, user_preferences), 40 | subscribers: data.subscribers, 41 | over_18: data.over18, 42 | title: data.title, 43 | subreddit_front: subreddit_front, 44 | } 45 | ret.links.push(obj) 46 | } 47 | resolve(ret) 48 | } 49 | })() 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /inc/teddit_api/handleSubreddit.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../../config') 3 | this.handleTedditApiSubreddit = async (json, req, res, from, api_type, api_target, subreddit) => { 4 | if(!config.api_enabled) { 5 | res.setHeader('Content-Type', 'application/json') 6 | let msg = { info: 'This instance do not support API requests. Please see https://codeberg.org/teddit/teddit#instances for instances that support API, or setup your own instance.' } 7 | return res.end(JSON.stringify(msg)) 8 | } 9 | 10 | console.log('Teddit API request - subreddit') 11 | let _json = json // Keep the original json 12 | if(from === 'redis') 13 | json = JSON.parse(json) 14 | 15 | if(api_type === 'rss') { 16 | let protocol = (config.https_enabled ? 'https' : 'http') 17 | let items = '' 18 | for(var i = 0; i < json.data.children.length; i++) { 19 | let link = json.data.children[i].data 20 | let thumbnail = '' 21 | let post_image = '' 22 | let is_self_link = false 23 | let valid_reddit_self_domains = ['reddit.com'] 24 | 25 | if(link.domain) { 26 | let tld = link.domain.split('self.') 27 | if(tld.length > 1) { 28 | if(!tld[1].includes('.')) { 29 | is_self_link = true 30 | link.url = teddifyUrl(link.url) 31 | } 32 | } 33 | if(config.valid_media_domains.includes(link.domain) || valid_reddit_self_domains.includes(link.domain)) { 34 | is_self_link = true 35 | link.url = teddifyUrl(link.url) 36 | } 37 | } 38 | 39 | if(link.preview && link.thumbnail !== 'self') { 40 | if(!link.url.startsWith('/r/') && isGif(link.url)) { 41 | let s = await downloadAndSave(link.thumbnail, 'thumb_') 42 | thumbnail = `${protocol}://${config.domain}${s}` 43 | } else { 44 | if(link.preview.images[0].resolutions[0]) { 45 | let s = await downloadAndSave(link.preview.images[0].resolutions[0].url, 'thumb_') 46 | thumbnail = `${protocol}://${config.domain}${s}` 47 | if(!isGif(link.url) && !link.post_hint.includes(':video')) { 48 | s = await downloadAndSave(link.preview.images[0].source.url) 49 | post_image = `${protocol}://${config.domain}${s}` 50 | } 51 | } 52 | } 53 | } 54 | 55 | link.permalink = `${protocol}://${config.domain}${link.permalink}` 56 | 57 | if(is_self_link) 58 | link.url = link.permalink 59 | 60 | if(req.query.hasOwnProperty('full_thumbs')) { 61 | if(!post_image) 62 | post_image = thumbnail 63 | 64 | thumbnail = post_image 65 | } 66 | 67 | let enclosure = '' 68 | if(thumbnail != '') { 69 | let mime = '' 70 | let ext = thumbnail.split('.').pop() 71 | if(ext === 'png') 72 | mime = 'image/png' 73 | else 74 | mime = 'image/jpeg' 75 | enclosure = `` 76 | } 77 | 78 | let append_desc_html = `
[link] [comments]` 79 | 80 | items += ` 81 | 82 | ${link.title} 83 | ${link.author} 84 | ${link.created} 85 | ${new Date(link.created_utc*1000).toGMTString()} 86 | ${link.domain} 87 | ${link.id} 88 | ${thumbnail} 89 | ${enclosure} 90 | ${link.permalink} 91 | ${link.url} 92 | 93 | ${link.num_comments} 94 | ${link.ups} 95 | ${link.stickied} 96 | ${is_self_link} 97 | 98 | ` 99 | } 100 | 101 | let r_subreddit = '/r/' + subreddit 102 | let title = r_subreddit 103 | let link = `${protocol}://${config.domain}${r_subreddit}` 104 | if(subreddit === '/') { 105 | r_subreddit = 'frontpage' 106 | title = 'teddit frontpage' 107 | link = `${protocol}://${config.domain}` 108 | } 109 | 110 | let xml_output = 111 | ` 112 | 113 | 114 | 115 | ${title} 116 | ${link} 117 | Subreddit feed for: ${r_subreddit} 118 | ${items} 119 | 120 | ` 121 | res.setHeader('Content-Type', 'application/rss+xml') 122 | return res.end(xml_output) 123 | } else { 124 | res.setHeader('Content-Type', 'application/json') 125 | if(api_target === 'reddit') { 126 | return res.end(JSON.stringify(json)) 127 | } else { 128 | let processed_json = await processJsonSubreddit(_json, from, null, req.cookies) 129 | 130 | let protocol = (config.https_enabled ? 'https' : 'http') 131 | for(var i = 0; i < processed_json.links.length; i++) { 132 | let link = processed_json.links[i] 133 | let valid_reddit_self_domains = ['reddit.com'] 134 | let is_self_link = false 135 | 136 | if(link.domain) { 137 | let tld = link.domain.split('self.') 138 | if(tld.length > 1) { 139 | if(!tld[1].includes('.')) { 140 | is_self_link = true 141 | link.url = teddifyUrl(link.url) 142 | } 143 | } 144 | if(config.valid_media_domains.includes(link.domain) || valid_reddit_self_domains.includes(link.domain)) { 145 | is_self_link = true 146 | link.url = teddifyUrl(link.url) 147 | } 148 | } 149 | 150 | link.permalink = `${protocol}://${config.domain}${link.permalink}` 151 | 152 | if(is_self_link) 153 | link.url = link.permalink 154 | 155 | if(link.images) { 156 | if(link.images.thumb !== 'self') { 157 | link.images.thumb = `${protocol}://${config.domain}${link.images.thumb}` 158 | } 159 | } 160 | } 161 | 162 | return res.end(JSON.stringify(processed_json)) 163 | } 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /inc/teddit_api/handleUser.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | const config = require('../../config') 3 | this.handleTedditApiUser = async (json, req, res, from, api_type, api_target, user) => { 4 | if(!config.api_enabled) { 5 | res.setHeader('Content-Type', 'application/json') 6 | let msg = { info: 'This instance do not support API requests. Please see https://codeberg.org/teddit/teddit#instances for instances that support API, or setup your own instance.' } 7 | return res.end(JSON.stringify(msg)) 8 | } 9 | 10 | console.log('Teddit API request - user') 11 | let _json = json // Keep the original json 12 | if(from === 'redis') 13 | json = JSON.parse(json) 14 | 15 | let protocol = (config.https_enabled ? 'https' : 'http') 16 | let link = `${protocol}://${config.domain}/user/${user}` 17 | 18 | if(api_type === 'rss') { 19 | let items = '' 20 | let posts_limit = 25 21 | 22 | if(json.overview.data.children.length <= posts_limit) { 23 | posts_limit = json.overview.data.children.length 24 | } 25 | 26 | for(var i = 0; i < posts_limit; i++) { 27 | let post = json.overview.data.children[i].data 28 | let post_id = post.permalink.split('/').slice(-2)[0] + '/' 29 | let url = post.permalink.replace(post_id, '') 30 | let permalink = `${protocol}://${config.domain}${post.permalink}` 31 | let comments_url = `${protocol}://${config.domain}${url}` 32 | let kind = json.overview.data.children[i].kind 33 | 34 | let t1_elements = '' 35 | let t3_elements = '' 36 | if(kind === 't1') { 37 | let append_desc_html = `
[link] [comments]` 38 | t1_elements = ` 39 | 40 | ${comments_url} 41 | ` 42 | } 43 | if(kind === 't3') { 44 | let s = await downloadAndSave(post.thumbnail, 'thumb_') 45 | let thumbnail = '' 46 | let enclosure = '' 47 | if(s !== 'self' && s != '') { 48 | let img = `${protocol}://${config.domain}${s}` 49 | thumbnail = `${img}` 50 | 51 | let mime = '' 52 | let ext = s.split('.').pop() 53 | if(ext === 'png') 54 | mime = 'image/png' 55 | else 56 | mime = 'image/jpeg' 57 | enclosure = `` 58 | } 59 | let append_desc_html = `submitted by r/${post.subreddit}` 60 | append_desc_html += `
[comments]` 61 | t3_elements = ` 62 | 63 | ${thumbnail} 64 | ${enclosure} 65 | ` 66 | } 67 | 68 | let title = post.title 69 | if(!post.title) 70 | title = post.link_title 71 | 72 | items += ` 73 | 74 | ${title} 75 | /u/${user} 76 | ${kind} 77 | ${post.subreddit} 78 | ${post.created_utc} 79 | ${new Date(post.created_utc*1000).toGMTString()} 80 | ${post.ups} 81 | ${permalink} 82 | ${post.edited} 83 | ${post.num_comments} 84 | ${post.over_18} 85 | ${t1_elements} 86 | ${t3_elements} 87 | 88 | ` 89 | } 90 | 91 | let xml_output = 92 | ` 93 | 94 | 95 | 96 | overview for ${user} 97 | ${link} 98 | ${items} 99 | 100 | ` 101 | res.setHeader('Content-Type', 'application/rss+xml') 102 | return res.end(xml_output) 103 | } else { 104 | res.setHeader('Content-Type', 'application/json') 105 | if(api_target === 'reddit') { 106 | return res.end(JSON.stringify(json)) 107 | } else { 108 | let processed_json = await processJsonUser(json, true, null, null, req.cookies) 109 | return res.end(JSON.stringify(processed_json)) 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teddit", 3 | "version": "0.3.1", 4 | "description": "A free and open source alternative Reddit front-end focused on privacy.", 5 | "homepage": "https://teddit.net", 6 | "bugs": { 7 | "url": "https://codeberg.org/teddit/teddit/issues" 8 | }, 9 | "license": "AGPL-3.0", 10 | "funding": [ 11 | { 12 | "type": "XMR", 13 | "url": "832ogRwuoSs2JGYg7wJTqshidK7dErgNdfpenQ9dzMghNXQTJRby1xGbqC3gW3GAifRM9E84J91VdMZRjoSJ32nkAZnaCEj" 14 | } 15 | ], 16 | "main": "app.js", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://codeberg.org/teddit/teddit" 20 | }, 21 | "scripts": { 22 | "test": "echo \"Error: no test specified\" && exit 1", 23 | "start": "node app.js" 24 | }, 25 | "dependencies": { 26 | "compression": "^1.7.4", 27 | "cookie-parser": "^1.4.5", 28 | "express": "^4.17.1", 29 | "helmet": "^4.2.0", 30 | "minizlib": "^2.1.2", 31 | "node-fetch": "^2.6.1", 32 | "postman-request": "^2.88.1-postman.27", 33 | "pug": "^3.0.2", 34 | "redis": "^3.0.2" 35 | }, 36 | "devDependencies": {} 37 | } 38 | -------------------------------------------------------------------------------- /static/css/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --darkbg: #0F0F0F; 3 | --darkbglight: #252525; 4 | --darklinkcolor: #599bff; 5 | } 6 | 7 | body.dark { 8 | background: var(--darkbg); 9 | color: #cacaca; 10 | } 11 | body.dark nav { 12 | background: #1f1f1f; 13 | } 14 | body.dark .top-links a { 15 | background: var(--darkbg); 16 | color: #bfbfbf; 17 | } 18 | body.dark header { 19 | background: var(--darkbglight); 20 | color: #f1f1f1; 21 | } 22 | body.dark #post header div a { 23 | color: var(--darklinkcolor); 24 | text-decoration: none; 25 | } 26 | body.dark a { 27 | color: #f5f5f5; 28 | } 29 | body.dark a:hover, body.dark a:focus { 30 | color: #3d8aff; 31 | text-decoration: underline; 32 | } 33 | body.dark #post header div a:hover, 34 | body.dark #post header div a:focus { 35 | text-decoration: underline; 36 | } 37 | body.dark input[type="submit"]:hover, 38 | body.dark input[type="submit"]:focus, 39 | body.dark .btn:hover, 40 | body.dark .btn:focus { 41 | background: white; 42 | color: black; 43 | text-decoration: none; 44 | } 45 | body.dark form legend { 46 | border-bottom: 1px solid #353535; 47 | } 48 | body.dark #post .title a { 49 | color: var(--darklinkcolor); 50 | } 51 | body.dark #post .submitted { 52 | color: #a5a5a5; 53 | } 54 | body.dark #post .usertext-body { 55 | background: #0a0a0a; 56 | border: 1px solid #404040; 57 | } 58 | body.dark .infobar { 59 | background-color: #d2d2d2; 60 | color: #2f2f2f; 61 | } 62 | body.dark .infobar.blue { 63 | background: #c7e3f9; 64 | border: 1px solid #4b78a4; 65 | } 66 | body.dark .infobar a { 67 | color: #0356d4; 68 | } 69 | body.dark header .tabmenu li a { 70 | background: #3e3e3e; 71 | } 72 | body.dark header .tabmenu li a:hover, body.dark header .tabmenu li a:focus { 73 | text-decoration: underline; 74 | color: white; 75 | } 76 | body.dark #search { 77 | color: #d2d2d2; 78 | } 79 | body.dark .md { 80 | color: #dadada; 81 | } 82 | body.dark .md blockquote, body.dark .md del { 83 | color: #777777; 84 | } 85 | body.dark .md code, body.dark .md pre { 86 | background: black; 87 | color: white; 88 | } 89 | body.dark .comment .body blockquote { 90 | background: #313131; 91 | color: #afafaf; 92 | border-color: #464646; 93 | } 94 | body.dark .even-depth { 95 | background: var(--darkbg); 96 | } 97 | body.dark .odd-depth { 98 | background: var(--darkbglight); 99 | } 100 | 101 | body.dark .comment .comment { 102 | border-left: 1px solid #545454; 103 | } 104 | 105 | body.dark .comment .meta .created a { 106 | color: #7b7b7b; 107 | } 108 | body.dark .comment details summary { 109 | color: #868686; 110 | } 111 | body.dark .comment details summary::-webkit-details-marker, 112 | body.dark .comment details summary::marker { 113 | color: #868686; 114 | } 115 | body.dark #links .link .entry .title a h2 { 116 | color: #f0f0f0; 117 | } 118 | body.dark #links .link .entry .title a:visited h2 { 119 | color: #6f6f6f; 120 | } 121 | body.dark #links .link .image .no-image, 122 | body.dark #user .entry .image .no-image { 123 | filter: opacity(0.5); 124 | } 125 | body.dark #user .comment { 126 | width: 100%; 127 | background: var(--darkbg); 128 | } 129 | body.dark #links .link .upvotes { 130 | color: #858585; 131 | } 132 | body.dark .upvotes .arrow, 133 | body.dark .score .arrow { 134 | filter: opacity(0.5); 135 | } 136 | body.dark #links .link .entry .meta a { 137 | color: #c7c7c7; 138 | } 139 | body.dark #links .link .entry .selftext { 140 | background: #0a0a0a; 141 | border: 1px solid #404040; 142 | } 143 | body.dark #links .link .entry .meta .links .selftext a { 144 | color: var(--darklinkcolor); 145 | margin: 0; 146 | } 147 | body.dark #links .link .entry details .line { 148 | width: 16px; 149 | margin-top: 3px; 150 | background: black; 151 | border: 1px solid #6f6f6f; 152 | } 153 | body.dark .content .bottom img { 154 | filter: invert(1); 155 | } 156 | body.dark .container .content { 157 | border: 1px solid #5e5e5e; 158 | } 159 | body.dark input[type="submit"], 160 | body.dark .btn { 161 | background: black; 162 | color: white; 163 | } 164 | body.dark #post .crosspost { 165 | background: var(--darkbg); 166 | } 167 | body.dark .view-more-links a { 168 | background: black; 169 | color: white; 170 | } 171 | body.dark .md .md-spoiler-text:not(.revealed):active, 172 | body.dark .md .md-spoiler-text:not(.revealed):focus, 173 | body.dark .md .md-spoiler-text:not(.revealed):hover { 174 | background: white; 175 | color: black; 176 | } 177 | body.dark .comment .body a, 178 | body.dark .usertext-body a { 179 | color: #3d99fb; 180 | } 181 | body.dark header .tabmenu li.active a { 182 | background: #acacac; 183 | color: #151515; 184 | } 185 | body.dark #search form input[type="text"] { 186 | background: #0f0f0f; 187 | color: white; 188 | } 189 | body.dark footer { 190 | background: #2f2f2f; 191 | } 192 | body.dark footer a { 193 | color: #999; 194 | } 195 | body.dark .flair { 196 | color: #eaeaea !important; 197 | background-color: #404040 !important; 198 | } 199 | body.dark #sr-more-link { 200 | color: white; 201 | background: #0f0f0f; 202 | } 203 | body.dark #post .usertext-body .poll { 204 | border: 1px solid #404040; 205 | } 206 | -------------------------------------------------------------------------------- /static/css/sepia.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bodybg: #ccc9b8; /* #cac5ad; */ 3 | --bodytext: #53524b; 4 | --endsbg: #992c09; 5 | --headerbg: #bf360c; 6 | --headertext: white; 7 | --headerfaded: #ecc3b7; 8 | --linkbg: #e2dfd7; 9 | --linktext: #979692; 10 | --shadow: #9e9e9e; 11 | --buttonbg: #f2f0ea; 12 | --buttontext: #53524b; 13 | --infobarbg: #ebe6b6; 14 | --oddbg: #e2dfd7; 15 | --oddborder: #616161; 16 | --evenbg: #f2f0ea; 17 | --evenborder: #919191; 18 | --quotebg: #e9e9e9; 19 | --quoteborder: #bdbdbd; 20 | /* Overrides */ 21 | --graytext: #616161; 22 | /* Copied from default theme */ 23 | --linkcolor: #0645ad; 24 | --lightlinkcolor: #406bb3; 25 | } 26 | /* Main page */ 27 | body.sepia { 28 | background: var(--bodybg); 29 | color: var(--bodytext); 30 | } 31 | body.sepia nav { 32 | background: var(--endsbg); 33 | color: var(--headertext); 34 | } 35 | body.sepia .top-links { 36 | padding-bottom: 4px; 37 | background: var(--headerbg); 38 | } 39 | body.sepia .top-links a { 40 | padding-left: 4px; 41 | background: var(--headerbg); 42 | color: var(--headerfaded); 43 | } 44 | body.sepia #sr-more-link { 45 | margin-top: 4px; 46 | background: var(--buttonbg); 47 | } 48 | body.sepia header { 49 | margin-top: -8px; 50 | background: var(--headerbg); 51 | color: var(--headertext); 52 | } 53 | body.sepia header a { 54 | color: var(--headertext); 55 | } 56 | body.sepia header .tabmenu li a { 57 | background: none; 58 | color: var(--headerfaded); 59 | text-transform: uppercase; 60 | } 61 | body.sepia header .tabmenu li.active a { 62 | color: var(--headertext); 63 | } 64 | body.sepia #sidebar { 65 | width: calc(25% - 45px); 66 | } 67 | body.sepia #links { 68 | max-width: calc(100% - 32px); 69 | margin: 4px 16px; 70 | } 71 | body.sepia #links .link { 72 | width: calc(100% - 8px); 73 | margin: 6px 0; 74 | padding: 16px 8px 16px 0; 75 | background: var(--linkbg); 76 | color: var(--linktext); 77 | border-radius: 3px; 78 | box-shadow: 0 0 2px var(--shadow); 79 | } 80 | body.sepia .flair, 81 | body.sepia #links .link .entry .title span.flair, 82 | body.sepia #post .info .title span.flair { 83 | color: var(--bodytext); 84 | background-color: var(--linkbg); 85 | } 86 | body.sepia .upvotes .arrow { 87 | filter: brightness(90%); 88 | } 89 | body.sepia #links .link .entry .title a h2 { 90 | padding-right: 4px; 91 | } 92 | body.sepia #links .link .entry .title span { 93 | padding-left: 0; 94 | } 95 | body.sepia #user .upvotes, 96 | body.sepia #links .link .upvotes, 97 | body.sepia #links .link .entry .title span, 98 | body.sepia #links .link .entry .meta, 99 | body.sepia #links .link .entry .meta .links a { 100 | color: var(--linktext); 101 | } 102 | body.sepia button, 103 | body.sepia select, 104 | body.sepia input, 105 | body.sepia input[type="submit"], 106 | body.sepia #search input[type="text"], 107 | body.sepia #sr-more-link, 108 | body.sepia .btn, 109 | body.sepia .view-more-links a { 110 | margin-bottom: 0; 111 | background: var(--buttonbg); 112 | color: var(--buttontext); 113 | border-radius: 3px; 114 | border: none; 115 | box-shadow: 0 0 2px var(--shadow); 116 | } 117 | body.sepia .view-more-links a { 118 | margin-left: 16px; 119 | color: var(--linkcolor); 120 | font-weight: normal; 121 | } 122 | body.sepia #sr-more-link { 123 | border-radius: 3px 0 0 3px; 124 | } 125 | body.sepia input[type="checkbox"] { 126 | margin-top: 6px; 127 | } 128 | body.sepia footer { 129 | margin-top: 16px; 130 | padding: 8px 0 8px 20px; 131 | background: var(--endsbg); 132 | box-shadow: 0 0 2px var(--shadow); 133 | } 134 | body.sepia footer a { 135 | color: var(--headerfaded); 136 | } 137 | /* Search */ 138 | body.sepia #links.search { 139 | width: calc(100% - 40px); 140 | } 141 | /* Comments */ 142 | body.sepia #post header div a { 143 | color: var(--headerfaded); 144 | } 145 | body.sepia #post .score { 146 | color: var(--graytext); 147 | } 148 | body.sepia .score .arrow { 149 | filter: brightness(70%); 150 | } 151 | body.sepia #post .submitted, 152 | body.sepia #post .title .domain { 153 | color: var(--graytext); 154 | } 155 | body.sepia #post .usertext-body, 156 | body.sepia #post .crosspost { 157 | background: var(--oddbg); 158 | border-radius: 3px; 159 | border: 1px solid var(--oddbg); /* .crosspost disappears with border: none */ 160 | box-shadow: 0 0 2px var(--shadow); 161 | } 162 | body.sepia #post .crosspost { 163 | padding: 0 16px 16px 0; 164 | } 165 | body.sepia .infobar { 166 | background: var(--infobarbg); 167 | border-radius: 3px; 168 | box-shadow: 0 0 2px var(--shadow); 169 | } 170 | body.sepia .comment { 171 | background: var(--oddbg); 172 | border-left: 3px solid var(--oddborder); 173 | border-radius: 3px 0 0 3px; 174 | box-shadow: 1px 0 1px var(--shadow); 175 | } 176 | body.sepia .comment.even-depth { 177 | background: var(--evenbg); 178 | border-left: 3px solid var(--evenborder); 179 | box-shadow: 1px 0 1px var(--shadow); 180 | } 181 | body.sepia .comments > .comment { 182 | border: none; 183 | border-radius: 0; 184 | } 185 | body.sepia .comment .comment { 186 | margin: 8px 0; 187 | } 188 | body.sepia .comment details { 189 | padding-top: 8px; 190 | } 191 | body.sepia .comment details:not([open]) { 192 | padding-bottom: 8px; 193 | } 194 | body.sepia .comment .body blockquote { 195 | background: var(--quotebg); 196 | color: var(--bodytext); 197 | border-left: 3px solid var(--quoteborder); 198 | border-radius: 3px; 199 | box-shadow: 0 0 1px var(--shadow); 200 | } 201 | body.sepia .md { 202 | color: var(--bodytext); 203 | } 204 | body.sepia .md .md-spoiler-text:not(.revealed), 205 | body.sepia .md .md-spoiler-text:active:not(.revealed), 206 | body.sepia .md .md-spoiler-text:focus:not(.revealed), 207 | body.sepia .md .md-spoiler-text:hover:not(.revealed) { 208 | background: var(--bodytext); 209 | color: var(--bodytext); 210 | } 211 | body.sepia .md .md-spoiler-text:active:not(.revealed), 212 | body.sepia .md .md-spoiler-text:focus:not(.revealed), 213 | body.sepia .md .md-spoiler-text:hover:not(.revealed) { 214 | background: none; 215 | } 216 | body.sepia .comments > form button { 217 | margin: 12px 8px; 218 | padding: 8px; 219 | cursor: pointer; 220 | } 221 | body.sepia #post .usertext-body .poll { 222 | border: 1px solid #ccc9b8; 223 | } 224 | @media only screen and (max-width: 600px) { 225 | body.sepia #sidebar { 226 | width: 100%; 227 | } 228 | body.sepia #links .link .entry details[open] .preview { 229 | width: calc(100vw - 34px); 230 | margin-left: 3px; 231 | } 232 | body.sepia #links .link .entry .selftext { 233 | width: calc(100vw - 70px); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /static/css/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teddit-net/teddit/4b32040b32a8b313845b3963e49262c0d1276ca5/static/css/sprite.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teddit-net/teddit/4b32040b32a8b313845b3963e49262c0d1276ca5/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teddit-net/teddit/4b32040b32a8b313845b3963e49262c0d1276ca5/static/favicon.png -------------------------------------------------------------------------------- /static/kopimi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teddit-net/teddit/4b32040b32a8b313845b3963e49262c0d1276ca5/static/kopimi.gif -------------------------------------------------------------------------------- /static/pics/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | !flairs/ 6 | !thumbs/ 7 | !icons/ 8 | 9 | -------------------------------------------------------------------------------- /static/pics/flairs/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /static/pics/icons/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /static/pics/thumbs/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: MJ12bot 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /static/vids/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /views/about.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title about - teddit 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | .container 9 | .content 10 | h1 About 11 | p Teddit is a free and open source alternative Reddit front-end focused on privacy. Teddit doesn't require you to have JavaScript enabled in your browser. The source is available on Codeberg at https://codeberg.org/teddit/teddit. 12 | ul 13 | li No JavaScript or ads 14 | li All requests go through the backend, client never talks to Reddit 15 | li Prevents Reddit from tracking your IP or JavaScript fingerprint 16 | li Lightweight (teddit frontpage: ~30 HTTP requests with ~270 KB of data downloaded vs. Reddit frontpage: ~190 HTTP requests with ~24 MB) 17 | br 18 | a(href="/privacy") Privacy policy 19 | h2 Donating 20 | p(class="word-break") XMR: 832ogRwuoSs2JGYg7wJTqshidK7dErgNdfpenQ9dzMghNXQTJRby1xGbqC3gW3GAifRM9E84J91VdMZRjoSJ32nkAZnaCEj 21 | .bottom 22 | a(href="https://en.wikipedia.org/wiki/Piratbyr%C3%A5n#Kopimi", target="_blank") 23 | img(src="kopimi.gif") 24 | p.version v.0.3.1 25 | include includes/footer.pug 26 | -------------------------------------------------------------------------------- /views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | footer 2 | a(href="https://codeberg.org/teddit/teddit", target="_blank", rel="noopener noreferrer") https://codeberg.org/teddit/teddit/ 3 | -------------------------------------------------------------------------------- /views/includes/head.pug: -------------------------------------------------------------------------------- 1 | if(user_preferences.theme === 'auto') 2 | link(rel="stylesheet", type="text/css", href="/css/dark.css", media="(prefers-color-scheme: dark)") 3 | if(user_preferences.theme === 'dark') 4 | link(rel="stylesheet", type="text/css", href="/css/dark.css") 5 | if(user_preferences.theme === 'sepia') 6 | link(rel="stylesheet", type="text/css", href="/css/sepia.css") 7 | link(rel="stylesheet", type="text/css", href="/css/styles.css") 8 | link(rel="icon", type="image/png", sizes="32x32", href="/favicon.png") 9 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 10 | - 11 | if(!user_preferences) 12 | user_preferences = {} 13 | 14 | function kFormatter(num) { 15 | return Math.abs(num) > 999 ? Math.sign(num)*((Math.abs(num)/1000).toFixed(1)) + 'k' : Math.sign(num)*Math.abs(num) 16 | } 17 | 18 | function toDateString(time) { 19 | let d = new Date(); 20 | d.setTime(time*1000); 21 | return d.toDateString(); 22 | } 23 | 24 | function toUTCString(time) { 25 | let d = new Date(); 26 | d.setTime(time*1000); 27 | return d.toUTCString(); 28 | } 29 | 30 | function secondsToMMSS(time) { 31 | return new Date(time * 1000).toISOString().substr(14,5) 32 | } 33 | 34 | function getFileExtension(url) { 35 | try { 36 | url = new URL(url) 37 | let pathname = url.pathname 38 | let file_ext = pathname.substring(pathname.lastIndexOf('.') + 1) 39 | if(file_ext) { 40 | return file_ext 41 | } else { 42 | return '' 43 | } 44 | } catch (error) { 45 | console.error(`Invalid url supplied to getFileExtension(). URL: ${url}`, error) 46 | return '' 47 | } 48 | } 49 | 50 | function cleanTitle(s) { 51 | if(s) { 52 | var re = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g; 53 | var unescaped = { 54 | '&': '&', 55 | '&': '&', 56 | '<': '<', 57 | '<': '<', 58 | '>': '>', 59 | '>': '>', 60 | ''': "'", 61 | ''': "'", 62 | '"': '"', 63 | '"': '"' 64 | } 65 | return s.replace(re, (m) => { 66 | return unescaped[m] 67 | }) 68 | } else { 69 | return '' 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /views/includes/topbar.pug: -------------------------------------------------------------------------------- 1 | div#topbar 2 | nav 3 | .nav-item.left 4 | a(href="/") 5 | img(src="/favicon.png", alt="") 6 | | teddit 7 | .settings 8 | .icon-container 9 | a(href="/about") [about] 10 | .icon-container 11 | a(href="/preferences") [preferences] 12 | .top-links 13 | if user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits) 14 | a(href="/r/popular") Popular 15 | a(href="/r/all") All 16 | a(href="/saved") Saved 17 | each subreddit in user_preferences.subbed_subreddits 18 | a(href="/r/" + subreddit) #{subreddit} 19 | else 20 | a(href="/r/popular") Popular 21 | a(href="/r/all") All 22 | a(href="/saved") Saved 23 | a(href="/r/AskReddit") AskReddit 24 | a(href="/r/pics") pics 25 | a(href="/r/news") news 26 | a(href="/r/worldnews") worldnews 27 | a(href="/r/funny") funny 28 | a(href="/r/tifu") tifu 29 | a(href="/r/videos") videos 30 | a(href="/r/gaming") gaming 31 | a(href="/r/aww") aww 32 | a(href="/r/todayilearned") todayilearned 33 | a(href="/r/gifs") gifs 34 | a(href="/r/Art") Art 35 | a(href="/r/explainlikeimfive") explainlikeimfive 36 | a(href="/r/movies") movies 37 | a(href="/r/Jokes") Jokes 38 | a(href="/r/TwoXChromosomes") TwoXChromosomes 39 | a(href="/r/mildlyinteresting") mildlyinteresting 40 | a(href="/r/LifeProTips") LifeProTips 41 | a(href="/r/askscience") askscience 42 | a(href="/r/IAmA") IAmA 43 | a(href="/r/dataisbeautiful") dataisbeautiful 44 | a(href="/r/books") books 45 | a(href="/r/science") science 46 | a(href="/r/Showerthoughts") Showerthoughts 47 | a(href="/r/gadgets") gadgets 48 | a(href="/r/Futurology") Futurology 49 | a(href="/r/nottheonion") nottheonion 50 | a(href="/r/history") history 51 | a(href="/r/sports") sports 52 | a(href="/r/OldSchoolCool") OldSchoolCool 53 | a(href="/r/GetMotivated") GetMotivated 54 | a(href="/r/DIY") DIY 55 | a(href="/r/photoshopbattles") photoshopbattles 56 | a(href="/r/nosleep") nosleep 57 | a(href="/r/Music") Music 58 | a(href="/r/space") space 59 | a(href="/r/food") food 60 | a(href="/r/UpliftingNews") UpliftingNews 61 | a(href="/r/EarthPorn") EarthPorn 62 | a(href="/r/Documentaries") Documentaries 63 | a(href="/r/InternetIsBeautiful") InternetIsBeautiful 64 | a(href="/r/WritingPrompts") WritingPrompts 65 | a(href="/r/creepy") creepy 66 | a(href="/r/philosophy") philosophy 67 | a(href="/r/announcements") announcements 68 | a(href="/r/listentothis") listentothis 69 | a(href="/r/blog") blog 70 | a(href="/subreddits", id="sr-more-link") more » 71 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title teddit 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if json === null 9 | .reddit-error 10 | h2 Error 11 | p #{JSON.stringify(http_status_code)} 12 | p #{JSON.stringify(http_statustext)} 13 | if http_status_code == "401" || http_status_code == "503" 14 | p This error is probably caused because Reddit itself is down or having server issues. 15 | p Checking https://www.redditstatus.com might give some information. 16 | if http_status_code == "404" 17 | p The resource you were looking for was not found. 18 | else 19 | - var subreddit = '' 20 | if(user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits)) 21 | - subreddit = '/r/' + user_preferences.subbed_subreddits.join('+') 22 | header 23 | a(href="/", class="main") 24 | h1 teddit 25 | .bottom 26 | ul.tabmenu 27 | li(class=!sortby || sortby == 'hot' ? 'active' : '') 28 | a(href="" + subreddit + "/") hot 29 | li(class=sortby === 'new' ? 'active' : '') 30 | a(href="" + subreddit + "/new") new 31 | li(class=sortby === 'rising' ? 'active' : '') 32 | a(href="" + subreddit + "/rising") rising 33 | li(class=sortby === 'controversial' ? 'active' : '') 34 | a(href="" + subreddit + "/controversial") controversial 35 | li(class=sortby === 'top' ? 'active' : '') 36 | a(href="" + subreddit + "/top") top 37 | if !before && !after && sortby === 'hot' 38 | #intro 39 | h1 Welcome to teddit 40 | h2 the alternative, privacy respecting, front page of internet. 41 | #links.sr 42 | if sortby === 'top' || sortby === 'controversial' 43 | details 44 | summary 45 | if past === 'hour' 46 | span links from: past hour 47 | if past === 'day' 48 | span links from: past 24 hours 49 | if past === 'week' 50 | span links from: past week 51 | if past === 'month' 52 | span links from: past month 53 | if past === 'year' 54 | span links from: past year 55 | if past === 'all' 56 | span links from: all time 57 | ul 58 | li(class=past === 'hour' ? 'active' : '') 59 | a(href="?t=hour") past hour 60 | li(class=past === 'day' ? 'active' : '') 61 | a(href="?t=day") past 24 hours 62 | li(class=past === 'week' ? 'active' : '') 63 | a(href="?t=week") past week 64 | li(class=past === 'month' ? 'active' : '') 65 | a(href="?t=month") past month 66 | li(class=past === 'year' ? 'active' : '') 67 | a(href="?t=year") past year 68 | li(class=past === 'all' ? 'active' : '') 69 | a(href="?t=all") all time 70 | each link in json.links 71 | .link 72 | .upvotes 73 | .arrow 74 | span #{kFormatter(link.ups)} 75 | .arrow.down 76 | .image 77 | if(link.images) 78 | if link.is_self_link 79 | a(href="" + link.permalink + "") 80 | img(src=""+ link.images.thumb +"", alt="") 81 | else 82 | a(href="" + link.url + "", rel="noopener noreferrer") 83 | img(src=""+ link.images.thumb +"", alt="") 84 | else 85 | a(href="" + link.permalink + "") 86 | .no-image no image 87 | .entry 88 | .title 89 | if link.is_self_link 90 | a(href="" + link.permalink + "") 91 | h2 #{cleanTitle(link.title)} 92 | span (#{link.domain}) 93 | else 94 | a(href="" + link.url + "", rel="noopener noreferrer") 95 | h2 #{cleanTitle(link.title)} 96 | span (#{link.domain}) 97 | .meta 98 | p.submitted submitted 99 | span(title="" + toUTCString(link.created) + "") #{timeDifference(link.created)} by 100 | if link.author === '[deleted]' 101 | span(class="deleted") [deleted] 102 | else 103 | a(href="/u/" + link.author + "") 104 | | #{link.author} 105 | span(class="to") to 106 | a(href="/r/" + link.subreddit + "") 107 | | #{link.subreddit} 108 | .links 109 | if link.over_18 110 | span.tag.nsfw NSFW 111 | if link.selftext_html 112 | details 113 | summary 114 | .line 115 | .line 116 | .line 117 | .selftext 118 | != unescape(link.selftext_html, user_preferences) 119 | if (link.images && link.images.preview) 120 | style. 121 | details.preview-container img { 122 | width: 100% !important; 123 | height: auto !important; 124 | max-width: none !important; 125 | max-height: none !important; 126 | opacity: 0; 127 | } 128 | details.preview-container[open][data-url="#{link.images.preview}"] .preview { 129 | width: 100%; 130 | height: auto; 131 | background-image: url('#{link.images.preview}'); 132 | background-repeat: no-repeat; 133 | background-size: contain; 134 | } 135 | details.preview-container(data-url="" + link.images.preview + "") 136 | summary 137 | span ▶ 138 | .preview 139 | img(src=""+ link.images.thumb +"", alt="") 140 | a(href="" + link.permalink + "", class="comments") #{link.num_comments} comments 141 | - 142 | let back_url = "/" + sortby + "§2t="+ (past ? past : '') +"" 143 | if(before && !subreddit_front) 144 | back_url = "/" + sortby + "§2t="+ (past ? past : '') +"§1before=" + before + "" 145 | if(after) 146 | back_url = "/" + sortby + "§2t=" + (past ? past : '') + "§1after=" + after + "" 147 | - let saved_post = false 148 | if user_preferences.saved 149 | each post_id in user_preferences.saved 150 | if post_id === link.id 151 | - saved_post = true 152 | if saved_post 153 | a(href="/unsave/" + link.id + "/?rk=" + redis_key + "&b=" + back_url + "") unsave 154 | else 155 | a(href="/save/" + link.id + "/?rk=" + redis_key + "&b=" + back_url + "") save 156 | if json.info.before || json.info.after 157 | .view-more-links 158 | - var subreddit = 'all' 159 | if(user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits)) 160 | - subreddit = user_preferences.subbed_subreddits.join('+') 161 | if json.info.after 162 | a(href="/r/" + subreddit + "/" + sortby + "?t=" + (past ? past : '') + "&after=" + json.info.after + "") next › 163 | #search 164 | form(action="/r/all/search", method="GET") 165 | div 166 | label(for="q") search 167 | input(type="text", name="q", id="q", placeholder="search") 168 | div 169 | label(for="nsfw") include NSFW results 170 | input(type="checkbox", name="nsfw", id="nsfw", checked="checked") 171 | input(type="submit", value="search") 172 | include includes/footer.pug 173 | -------------------------------------------------------------------------------- /views/post.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title #{cleanTitle(post.title)} : #{subreddit} 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if post === null 9 | h1 Error occured 10 | p #{JSON.stringify(error_data)} 11 | else 12 | #post 13 | if (post.over_18 && instance_nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || (post.over_18 && user_preferences.nsfw_enabled === 'false') 14 | .nsfw-warning 15 | span 18+ 16 | h2 You must be 18+ to view this community 17 | p You must be at least eighteen years old to view this content. Are you over eighteen and willing to see adult content? 18 | a(href="/") No thank you 19 | a(href="?nsfw_enabled=true") Continue 20 | p If you continue, nsfw_enabled cookie preference will be automatically set to true. 21 | else 22 | header 23 | div 24 | p subreddit: 25 | a(href="/r/" + subreddit + "") 26 | p /r/#{subreddit} 27 | if user_preferences.post_media_max_height 28 | if(post_media_max_heights.hasOwnProperty(user_preferences.post_media_max_height)) 29 | style. 30 | #post .image img, #post .video video { 31 | max-height: #{post_media_max_heights[user_preferences.post_media_max_height]}px; 32 | max-width: 100%; 33 | } 34 | else if(!isNaN(user_preferences.post_media_max_height)) 35 | style. 36 | #post .image img, #post .video video { 37 | max-height: #{user_preferences.post_media_max_height}px; 38 | max-width: 100%; 39 | } 40 | .info 41 | .score 42 | div.arrow 43 | span #{kFormatter(post.ups)} 44 | if user_preferences.show_upvoted_percentage === 'true' 45 | - let downvoted = parseInt(post.ups * (1 - post.upvote_ratio)) 46 | span.ratio(title="~"+ downvoted +" downvoted") #{(post.upvote_ratio * 100).toFixed(0)}% 47 | div.arrow.down 48 | .title 49 | a(href="" + post.url + "", rel="noopener noreferrer") 50 | h2 #{cleanTitle(post.title)} 51 | != post.link_flair 52 | span(class="domain") (#{post.domain}) 53 | p.submitted 54 | span(title="" + toUTCString(post.created) + "") submitted #{timeDifference(post.created)} by 55 | if post.author === '[deleted]' 56 | span [deleted] 57 | else 58 | a(href="/u/" + post.author + "") 59 | | #{post.author} 60 | != post.user_flair 61 | .links 62 | if post.over_18 63 | span.tag.nsfw NSFW 64 | - 65 | let back_url = "/r/" + subreddit + "/comments/" + post.id 66 | let saved_post = false 67 | if user_preferences.saved 68 | each post_id in user_preferences.saved 69 | if post_id === post.id 70 | - saved_post = true 71 | if saved_post 72 | a(href="/unsave/" + post.id + "/?rk=" + redis_key + "&b=" + back_url + "") unsave 73 | else 74 | a(href="/save/" + post.id + "/?rk=" + redis_key + "&b=" + back_url + "") save 75 | if post.crosspost.is_crosspost === true 76 | .crosspost 77 | .title 78 | a(href="" + post.crosspost.permalink + "", rel="noopener noreferrer") 79 | h2 #{cleanTitle(post.crosspost.title)} 80 | span(class="domain") (#{post.domain}) 81 | .num_comments 82 | | #{post.crosspost.num_comments} comments 83 | .score 84 | div.arrow 85 | span #{kFormatter(post.crosspost.ups)} 86 | if user_preferences.show_upvoted_percentage === 'true' 87 | - let downvoted = parseInt(post.ups * (1 - post.upvote_ratio)) 88 | span.ratio(title="~"+ downvoted +" downvoted") #{post.upvote_ratio * 100}% 89 | div.arrow.down 90 | p.submitted 91 | span(title="" + toUTCString(post.crosspost.created) + "") submitted #{timeDifference(post.crosspost.created)} by 92 | if post.crosspost.author === '[deleted]' 93 | span [deleted] 94 | else 95 | a(href="/u/" + post.crosspost.author + "") 96 | | #{post.crosspost.author} 97 | != post.user_flair 98 | p.to to 99 | a(href="/r/" + post.crosspost.subreddit + "") 100 | | #{post.crosspost.subreddit} 101 | if !post.has_media 102 | if post.images 103 | .image 104 | a(href="" + post.images.source + "") 105 | img(src="" + post.images.source + "", alt="") 106 | else 107 | if post.media.not_hosted_in_reddit 108 | .video 109 | a(href="" + post.media.source + "") 110 | img(src=""+ post.media.source +"") 111 | p Embed URL: 112 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 113 | p #{post.media.embed_src} 114 | else 115 | .video 116 | video(controls="controls", autoplay="autoplay", loop="loop") 117 | source(src="" + post.media.source + "", type="video/mp4") 118 | | Your browser does not support the video element. 119 | a(href="" + post.media.source + "") [media] 120 | else 121 | if !post.has_media 122 | if post.gallery 123 | .gallery 124 | each item in post.gallery_items 125 | .item 126 | div 127 | a(href="" + item.large + "", target="_blank") 128 | img(src=""+ item.thumbnail +"", alt="") 129 | a(href="" + item.source + "", target="_blank", class="source-link") 130 | small source 131 | if post.images 132 | .image 133 | a(href="" + post.images.source + "") 134 | img(src="" + post.images.source + "", alt="") 135 | else 136 | if post.media 137 | if post.media.not_hosted_in_reddit 138 | if post.media.source === 'YouTube' 139 | .video 140 | .title 141 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 142 | p #{cleanTitle(post.media.title)} 143 | span(class="domain") (#{post.domain}) 144 | .video-holder 145 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 146 | img(src="" + post.media.thumbnail + "") 147 | div(class="youtube-info") 148 | p YouTube video info: 149 | p #{cleanTitle(post.media.title)} #{post.media.embed_src} 150 | p #{post.media.author_name} #{post.media.author_url} 151 | else 152 | if post.media.source === 'external' 153 | if post.images 154 | .image 155 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 156 | img(src="" + post.images.source + "", alt="") 157 | if !post.media.embed_src.startsWith("https://twitter.com") 158 | p 159 | | source: 160 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 161 | p(class="source-url") #{post.media.embed_src} 162 | else 163 | .video 164 | a(href="" + post.media.source + "") 165 | img(src="" + post.media.source + "") 166 | p Embed URL: 167 | a(href="" + post.media.embed_src + "", target="_blank", rel="noopener noreferrer") 168 | p #{post.media.embed_src} 169 | else 170 | .video 171 | video(controls="controls", autoplay="autoplay", loop="loop") 172 | source(src="" + post.media.source + "", type="video/mp4") 173 | | Your browser does not support the video element. 174 | a(href="" + post.media.source + "") [media] 175 | if post.selftext 176 | div.usertext-body !{post.selftext} 177 | if post.poll_data 178 | .poll 179 | .votes #{post.poll_data.total_vote_count} votes 180 | if !post.poll_data.options[0].vote_count 181 | em Cannot fetch poll data (either the poll is only for logged in users, or the result is shown after voting is complete). 182 | br 183 | em Showing only voting options: 184 | br 185 | each option in post.poll_data.options 186 | .option 187 | if(option.vote_count) 188 | - let perc = option.vote_count / post.poll_data.total_vote_count * 100 189 | .background(style="width:" + perc + "%") 190 | .vote_count #{option.vote_count} (#{perc.toFixed(0)} %) 191 | .text #{option.text} 192 | else 193 | .vote_count 194 | .text #{option.text} 195 | .meta 196 | if post.poll_data.voting_end_timestamp < new Date().getTime() 197 | em voting ended #{timeDifference(post.poll_data.voting_end_timestamp/1000)} 198 | else 199 | em voting will end in #{timeDifference(post.poll_data.voting_end_timestamp/1000, true)} 200 | if post.contest_mode 201 | .infobar.blue 202 | p this thread is in contest mode - contest mode randomizes comment sorting and hides scores. 203 | if viewing_comment 204 | div(class="infobar", id="c") 205 | p you are viewing a single comment's thread. 206 | a(href="" + post_url + "") view the rest of the comments → 207 | .comments-info 208 | p all #{post.num_comments} comments 209 | .comments-sort 210 | details 211 | summary 212 | if sortby === 'confidence' 213 | span sorted by: best 214 | if sortby === 'top' 215 | span sorted by: top 216 | if sortby === 'new' 217 | span sorted by: new 218 | if sortby === 'controversial' 219 | span sorted by: controversial 220 | if sortby === 'old' 221 | span sorted by: old 222 | if sortby === 'qa' 223 | span sorted by: q&a 224 | ul 225 | li(class=sortby === 'confidence' ? 'active' : '') 226 | a(href="?sort=confidence") best 227 | li(class=sortby === 'top' ? 'active' : '') 228 | a(href="?sort=top") top 229 | li(class=sortby === 'new' ? 'active' : '') 230 | a(href="?sort=new") new 231 | li(class=sortby === 'controversial' ? 'active' : '') 232 | a(href="?sort=controversial") controversial 233 | li(class=sortby === 'old' ? 'active' : '') 234 | a(href="?sort=old") old 235 | li(class=sortby === 'qa' ? 'active' : '') 236 | a(href="?sort=qa") Q&A 237 | != comments 238 | include includes/footer.pug 239 | -------------------------------------------------------------------------------- /views/preferences.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title preferences - teddit 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | .container 9 | .content 10 | h1 Preferences 11 | form(action="/saveprefs", method="POST") 12 | legend Privacy 13 | .setting 14 | label(for="domain_twitter") Replace Twitter links with Nitter (blank to disable): 15 | if(user_preferences.domain_twitter != '' && typeof(user_preferences.domain_twitter) != 'undefined') 16 | input(type="text", name="domain_twitter", id="domain_twitter", value="" + user_preferences.domain_twitter + "", placeholder="e.g. nitter.net") 17 | else 18 | input(type="text", name="domain_twitter", id="domain_twitter", placeholder="e.g. nitter.net") 19 | .setting 20 | label(for="domain_youtube") Replace YouTube links with Invidious (blank to disable): 21 | if(user_preferences.domain_youtube != '' && typeof(user_preferences.domain_youtube) != 'undefined') 22 | input(type="text", name="domain_youtube", id="domain_youtube", value="" + user_preferences.domain_youtube + "", placeholder="e.g. invidious.site") 23 | else 24 | input(type="text", name="domain_youtube", id="domain_youtube", placeholder="e.g. invidious.site") 25 | .setting 26 | label(for="domain_instagram") Replace Instagram links with Bibliogram (blank to disable): 27 | if(user_preferences.domain_instagram != '' && typeof(user_preferences.domain_instagram) != 'undefined') 28 | input(type="text", name="domain_instagram", id="domain_instagram", value="" + user_preferences.domain_instagram + "", placeholder="e.g. bibliogram.art") 29 | else 30 | input(type="text", name="domain_instagram", id="domain_instagram", placeholder="e.g. bibliogram.art") 31 | legend Display 32 | .setting 33 | label(for="theme") Theme: 34 | select(id="theme", name="theme") 35 | if(!user_preferences.theme || user_preferences.theme === 'auto') 36 | option(value="auto", selected="selected") Auto 37 | option(value="") White 38 | option(value="dark") Dark 39 | option(value="sepia") Sepia 40 | if(user_preferences.theme == '') 41 | option(value="auto") Auto 42 | option(value="", selected="selected") White 43 | option(value="dark") Dark 44 | option(value="sepia") Sepia 45 | if(user_preferences.theme === 'dark') 46 | option(value="auto") Auto 47 | option(value="") White 48 | option(value="dark", selected="selected") Dark 49 | option(value="sepia") Sepia 50 | if(user_preferences.theme === 'sepia') 51 | option(value="auto") Auto 52 | option(value="") White 53 | option(value="dark") Dark 54 | option(value="sepia", selected="selected") Sepia 55 | .setting 56 | label(for="flairs") Show flairs: 57 | if(!user_preferences.flairs || user_preferences.flairs == 'true') 58 | input(type="checkbox", name="flairs", id="flairs", checked="checked") 59 | else 60 | input(type="checkbox", name="flairs", id="flairs") 61 | .setting 62 | label(for="highlight_controversial") Show a dagger (†) on comments voted controversial: 63 | if(!user_preferences.highlight_controversial || user_preferences.highlight_controversial == 'true') 64 | input(type="checkbox", name="highlight_controversial", id="highlight_controversial", checked="checked") 65 | else 66 | input(type="checkbox", name="highlight_controversial", id="highlight_controversial") 67 | .setting 68 | label(for="nsfw_enabled") Show NSFW content: 69 | if (instance_config.nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false' 70 | input(type="checkbox", name="nsfw_enabled", id="nsfw_enabled") 71 | else 72 | input(type="checkbox", name="nsfw_enabled", id="nsfw_enabled", checked="checked") 73 | .setting 74 | label(for="post_media_max_height") Media size in posts: 75 | select(id="post_media_max_height", name="post_media_max_height") 76 | - 77 | let max_heights_html = '' 78 | let user_key = user_preferences.post_media_max_height 79 | if(!user_key || user_key == '') 80 | user_key = 'medium' 81 | 82 | for(let key in instance_config.post_media_max_heights) { 83 | if(instance_config.post_media_max_heights.hasOwnProperty(key)) 84 | max_heights_html += `` 85 | } 86 | != max_heights_html 87 | .setting 88 | label(for="collapse_child_comments") Collapse child comments automatically: 89 | if(user_preferences.collapse_child_comments == 'true') 90 | input(type="checkbox", name="collapse_child_comments", id="collapse_child_comments", checked="checked") 91 | else 92 | input(type="checkbox", name="collapse_child_comments", id="collapse_child_comments") 93 | .setting 94 | label(for="show_upvoted_percentage") Show upvote ratio percentage in posts: 95 | if(user_preferences.show_upvoted_percentage == 'true') 96 | input(type="checkbox", name="show_upvoted_percentage", id="show_upvoted_percentage", checked="checked") 97 | else 98 | input(type="checkbox", name="show_upvoted_percentage", id="show_upvoted_percentage") 99 | small(class="notice") Preferences are stored client-side using cookies without any personal information. 100 | br 101 | input(type="submit", value="Save preferences") 102 | a(href="/resetprefs", class="btn") Reset preferences 103 | .bottom-prefs 104 | .setting 105 | details 106 | summary 107 | span Show subscribed subreddits 108 | if user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits) 109 | ul.subreddit-listing 110 | each subreddit in user_preferences.subbed_subreddits 111 | li 112 | a(href="/unsubscribe/" + subreddit + "/?b=/preferences", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 113 | a(href="/r/" + subreddit) #{subreddit} 114 | else 115 | small no subscribed subreddits 116 | form(action="/export_prefs", method="POST", class="export-import-form") 117 | if preferences_key 118 | details(open) 119 | summary 120 | span Export preferences 121 | .setting 122 | small By exporting your preferences you can transfer your subscribed subreddits and preferences to another device. Or you could create a bookmark if you tend to delete your cookies frequently. 123 | br 124 | label(for="export_saved") Export saved posts: 125 | input(type="checkbox", name="export_saved", id="export_saved") 126 | br 127 | input(type="submit", value="Export preferences") 128 | if preferences_key 129 | - var protocol = 'http' 130 | if instance_config.https_enabled === true 131 | - var protocol = 'https' 132 | p Visit this URL to import your preferences: 133 | a(href=`${protocol}://${instance_config.domain}/import_prefs/${preferences_key}`) #{protocol}://#{instance_config.domain}/import_prefs/#{preferences_key} 134 | else 135 | details 136 | summary 137 | span Export preferences 138 | .setting 139 | small By exporting your preferences you can transfer your subscribed subreddits and preferences to another device. Or you could create a bookmark if you tend to delete your cookies frequently. 140 | br 141 | small If you are exporting to a file, please save your preferences first! 142 | br 143 | label(for="export_saved") Export saved posts: 144 | input(type="checkbox", name="export_saved", id="export_saved") 145 | br 146 | label(for="export_to_file") Export preferences to a JSON file: 147 | input(type="checkbox", name="export_to_file", id="export_to_file") 148 | br 149 | input(type="submit", value="Export preferences") 150 | form(action="/import_prefs", method="POST", class="export-import-form", enctype="multipart/form-data") 151 | details 152 | summary 153 | span Import JSON preferences file 154 | .setting 155 | small All your current preferences and saved posts will be reseted and the settings from the JSON file will be used instead. 156 | br 157 | input(type="file", name="file", id="file") 158 | br 159 | input(type="submit", value="Import preferences") 160 | include includes/footer.pug 161 | -------------------------------------------------------------------------------- /views/privacypolicy.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title privacy policy - teddit 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | .container 9 | .content 10 | h1 Privacy policy 11 | p This document concerns what data you provide to this website, the purpose of the data, how the data is stored, and how the data can be removed. 12 | h2 Data you directly provide 13 | p None. 14 | h2 Data you passively provide 15 | p By default teddit logs fetched subreddit or post URL for debugging reasons. 16 | p No identifying information is logged, such as the visitor's cookie, timestamp, user-agent, or IP address. Here are a couple lines to serve as an example how the debug log looks like: 17 | code 18 | | Got frontpage key from redis. 19 | br 20 | | Fetched the JSON from reddit.com/r/privacytoolsIO. 21 | br 22 | | Got frontpage key from redis. 23 | br 24 | | Fetched the JSON from reddit.com/r/OTMemes/comments/k311hu/we_all_know_sequels_refers_to_the_sequel_trilogy/. 25 | br 26 | | Got frontpage key from redis. 27 | br 28 | | Fetched the JSON from reddit.com/r/aww/comments/k31ddb/a_lot_of_request_to_see_the_dry_cat_here_she_is/. 29 | h2 Data stored in your browser 30 | p This website provides an option to store site preferences, such as the theme without an account. Using this feature will store a cookie in the visitor's browser containing their preferences. This cookie is sent on every request and does not contain any identifying information. 31 | p You can remove this data from your browser by using your browser's cookie-related controls to delete the data. 32 | include includes/footer.pug 33 | -------------------------------------------------------------------------------- /views/saved.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title saved 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if json === null 9 | h1 No saved posts 10 | else 11 | header 12 | a(href="/", class="main") 13 | h1 teddit 14 | .bottom 15 | a(href="/saved", class="subreddit") 16 | h2 saved 17 | #links.sr 18 | if json.links.length === 0 19 | p No saved posts 20 | else 21 | each link in json.links 22 | .link 23 | .upvotes 24 | .arrow 25 | span #{kFormatter(link.ups)} 26 | .arrow.down 27 | .image 28 | if link.images 29 | if link.is_self_link 30 | a(href="" + link.permalink + "") 31 | img(src="" + link.images.thumb + "", alt="") 32 | else 33 | a(href=""+ link.url +"", rel="noopener noreferrer") 34 | img(src="" + link.images.thumb + "", alt="") 35 | else 36 | a(href="" + link.permalink + "") 37 | .no-image no image 38 | .entry 39 | .title 40 | if link.is_self_link 41 | a(href="" + link.permalink + "") 42 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 43 | != link.link_flair 44 | span (#{link.domain}) 45 | else 46 | a(href="" + link.url + "", rel="noopener noreferrer") 47 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 48 | != link.link_flair 49 | span (#{link.domain}) 50 | .meta 51 | p.submitted submitted 52 | span(title="" + toUTCString(link.created) + "") #{timeDifference(link.created)} by 53 | if link.author === '[deleted]' 54 | span(class="deleted") [deleted] 55 | else 56 | a(href="/u/" + link.author + "") 57 | | #{link.author} 58 | != link.user_flair 59 | p.to to 60 | a(href="/r/" + link.subreddit + "") 61 | | #{link.subreddit} 62 | if link.stickied 63 | span(class="green") stickied 64 | .links 65 | if link.over_18 66 | span.tag.nsfw NSFW 67 | if link.selftext_html 68 | details 69 | summary 70 | .line 71 | .line 72 | .line 73 | .selftext 74 | != unescape(link.selftext_html) 75 | if (link.images && link.images.preview) 76 | style. 77 | details.preview-container img { 78 | width: 100% !important; 79 | height: auto !important; 80 | max-width: none !important; 81 | max-height: none !important; 82 | opacity: 0; 83 | } 84 | details.preview-container[open][data-url="#{link.images.preview}"] .preview { 85 | width: 100%; 86 | height: auto; 87 | background-image: url('#{link.images.preview}'); 88 | background-repeat: no-repeat; 89 | background-size: contain; 90 | } 91 | details.preview-container(data-url="" + link.images.preview + "") 92 | summary 93 | span ▶ 94 | .preview 95 | img(src=""+ link.images.thumb +"", alt="") 96 | a(href="" + link.permalink + "", class="comments") #{link.num_comments} comments 97 | a(href="/unsave/" + link.id + "") unsave 98 | include includes/footer.pug 99 | -------------------------------------------------------------------------------- /views/search.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | if no_query 5 | title search teddit 6 | else 7 | title search results for #{q} 8 | include includes/head.pug 9 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 10 | include includes/topbar.pug 11 | #search.sr.search-page 12 | form(action="/r/" + subreddit + "/search", method="GET") 13 | div 14 | label(for="q") search 15 | input(type="text", name="q", id="q", placeholder="search", value=""+ q +"") 16 | div 17 | label(for="restrict_sr") limit my search to r/#{subreddit} 18 | if !restrict_sr || restrict_sr === 'on' 19 | input(type="checkbox", name="restrict_sr", id="restrict_sr", checked="checked") 20 | else 21 | input(type="checkbox", name="restrict_sr", id="restrict_sr") 22 | div 23 | label(for="nsfw") include NSFW results 24 | if !nsfw || nsfw === 'on' 25 | input(type="checkbox", name="nsfw", id="nsfw", checked="checked") 26 | else 27 | input(type="checkbox", name="nsfw", id="nsfw") 28 | div 29 | //- Let me know if there's a better way to add selected attribute! 30 | label(for="sort") sorted by: 31 | select(name="sort", id="sort") 32 | if sortby === 'relevance' || !sortby 33 | option(value="relevance", selected="selected") relevance 34 | option(value="top") top 35 | option(value="new") new 36 | option(value="comments") comments 37 | if sortby === 'top' 38 | option(value="relevance") relevance 39 | option(value="top", selected="selected") top 40 | option(value="new") new 41 | option(value="comments") comments 42 | if sortby === 'new' 43 | option(value="relevance") relevance 44 | option(value="top") top 45 | option(value="new", selected="selected") new 46 | option(value="comments") comments 47 | if sortby === 'comments' 48 | option(value="relevance") relevance 49 | option(value="top") top 50 | option(value="new") new 51 | option(value="comments", selected="selected") comments 52 | div 53 | //- Let me know if there's a better way to add selected attribute! 54 | label(for="t") links from: 55 | select(name="t", id="t") 56 | if past === 'hour' 57 | option(value="hour", selected="selected") hour 58 | option(value="day") 24 hours 59 | option(value="week") week 60 | option(value="month") month 61 | option(value="year") year 62 | option(value="all") all time 63 | if past === 'day' 64 | option(value="hour") hour 65 | option(value="day", selected="selected") 24 hours 66 | option(value="week") week 67 | option(value="month") month 68 | option(value="year") year 69 | option(value="all") all time 70 | if past === 'week' 71 | option(value="hour") hour 72 | option(value="day") 24 hours 73 | option(value="week", selected="selected") week 74 | option(value="month") month 75 | option(value="year") year 76 | option(value="all") all time 77 | if past === 'month' 78 | option(value="hour") hour 79 | option(value="day") 24 hours 80 | option(value="week") week 81 | option(value="month", selected="selected") month 82 | option(value="year") year 83 | option(value="all") all time 84 | if past === 'year' 85 | option(value="hour") hour 86 | option(value="day") 24 hours 87 | option(value="week") week 88 | option(value="month") month 89 | option(value="year", selected="selected") year 90 | option(value="all") all time 91 | if past === 'all' || !past 92 | option(value="hour") hour 93 | option(value="day") 24 hours 94 | option(value="week") week 95 | option(value="month") month 96 | option(value="year") year 97 | option(value="all", selected="selected") all time 98 | input(type="submit", value="search") 99 | #links.search 100 | if json.posts.length === 0 && !no_query 101 | p no results 102 | else 103 | if json.suggested_subreddits 104 | .suggested-subreddits 105 | h3 suggested subreddits 106 | each suggested_subreddit in json.suggested_subreddits 107 | .link 108 | .entry 109 | - 110 | let subbed_to_this_subreddit = false 111 | let subbed = [] 112 | if(user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits)) 113 | subbed = user_preferences.subbed_subreddits 114 | for(let i = 0; i < subbed.length; i++) { 115 | if(subbed[i].toLowerCase() === suggested_subreddit.data.display_name.toLowerCase()) 116 | subbed_to_this_subreddit = true 117 | } 118 | .content 119 | .title 120 | a(href="" + suggested_subreddit.data.url + "", rel="noopener noreferrer") 121 | h2 #{suggested_subreddit.data.display_name_prefixed}: #{cleanTitle(suggested_subreddit.data.title)} 122 | .sub-button 123 | if subbed_to_this_subreddit 124 | a(href="/unsubscribe/" + suggested_subreddit.data.display_name + "?b=/r/" + subreddit + "/search?q=" + q + "§1nsfw=" + nsfw + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 125 | else 126 | a(href="/subscribe/" + suggested_subreddit.data.display_name + "?b=/r/" + subreddit + "/search?q=" + q + "§1nsfw=" + nsfw + "", class="sub-to-subreddit", title="subscriptions are saved in your browser's cookies") subscribe 127 | .description 128 | p #{cleanTitle(suggested_subreddit.data.public_description)} 129 | .meta 130 | p.subscribers #{kFormatter(suggested_subreddit.data.subscribers)} subscribers, 131 | p.submitted   created 132 | span(title="" + toUTCString(suggested_subreddit.data.created) + "") #{timeDifference(suggested_subreddit.data.created)} 133 | .links 134 | small 135 | a(href="/" + suggested_subreddit.data.display_name_prefixed + "/search?q=" + q + "&nsfw=" + nsfw + "&restrict_sr=on") search within #{suggested_subreddit.data.display_name_prefixed} 136 | if suggested_subreddit.data.over_18 137 | span.tag.nsfw NSFW 138 | a(href="/subreddits/search?q="+ q +"", class="btn") show more similar subreddits 139 | each link in json.posts 140 | .link 141 | .upvotes 142 | .arrow 143 | span #{kFormatter(link.ups)} 144 | .arrow.down 145 | .image 146 | if link.images 147 | if link.is_self_link 148 | a(href="" + link.permalink + "") 149 | img(src="" + link.images.thumb + "", alt="") 150 | else 151 | a(href=""+ link.url +"", rel="noopener noreferrer") 152 | img(src="" + link.images.thumb + "", alt="") 153 | else 154 | a(href="" + link.permalink + "") 155 | .no-image no image 156 | .entry 157 | .title 158 | if link.is_self_link 159 | a(href="" + link.permalink + "") 160 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 161 | != link.link_flair 162 | span (#{link.domain}) 163 | else 164 | a(href="" + link.url + "", rel="noopener noreferrer") 165 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 166 | != link.link_flair 167 | span (#{link.domain}) 168 | .meta 169 | p.submitted submitted 170 | span(title="" + toUTCString(link.created) + "") #{timeDifference(link.created)} by 171 | if link.author === '[deleted]' 172 | span(class="deleted") [deleted] 173 | else 174 | a(href="/u/" + link.author + "") 175 | | #{link.author} 176 | != link.user_flair 177 | p.to to 178 | a(href="/r/" + link.subreddit + "") 179 | | #{link.subreddit} 180 | if link.stickied 181 | span(class="green") stickied 182 | .links 183 | if link.over_18 184 | span.tag.nsfw NSFW 185 | a(href="" + link.permalink + "", class="comments") 186 | | #{link.num_comments} comments 187 | if json.before || json.after 188 | .view-more-links 189 | if json.before && !subreddit_front 190 | a(href="?q=" + q + "&restrict_sr=" + restrict_sr + "&nsfw=" + nsfw + "&before=" + json.before + "") ‹ prev 191 | if json.after 192 | a(href="?q=" + q + "&restrict_sr=" + restrict_sr + "&nsfw=" + nsfw + "&after=" + json.after + "") next › 193 | include includes/footer.pug 194 | -------------------------------------------------------------------------------- /views/subreddit.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title /r/#{subreddit} 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | - 9 | let show_nsfw_warning = false; 10 | if(subreddit_about) { 11 | if(subreddit_about.over18) { 12 | if((instance_nsfw_enabled === false && user_preferences.nsfw_enabled != 'true') || user_preferences.nsfw_enabled === 'false') { 13 | show_nsfw_warning = true; 14 | } 15 | } 16 | } 17 | if json === null 18 | h1 Error occured 19 | if error 20 | if json.error_data.reason === "private" 21 | h2 This is a private subreddit. 22 | p Error: #{JSON.stringify(json.error_data)} 23 | else 24 | if show_nsfw_warning === true 25 | .nsfw-warning 26 | span 18+ 27 | h2 You must be 18+ to view this community 28 | p You must be at least eighteen years old to view this content. Are you over eighteen and willing to see adult content? 29 | a(href="/") No thank you 30 | a(href="?nsfw_enabled=true") Continue 31 | p If you continue, nsfw_enabled cookie preference will be automatically set to true. 32 | else 33 | header 34 | a(href="/", class="main") 35 | h1 teddit 36 | .bottom 37 | if !subreddit.includes('+') 38 | a(href="/r/" + subreddit + "", class="subreddit") 39 | h2 #{subreddit} 40 | ul.tabmenu 41 | li(class=!sortby || sortby == 'hot' ? 'active' : '') 42 | a(href="/r/" + subreddit) hot 43 | li(class=sortby === 'new' ? 'active' : '') 44 | a(href="/r/" + subreddit + "/new") new 45 | li(class=sortby === 'rising' ? 'active' : '') 46 | a(href="/r/" + subreddit + "/rising") rising 47 | li(class=sortby === 'controversial' ? 'active' : '') 48 | a(href="/r/" + subreddit + "/controversial") controversial 49 | li(class=sortby === 'top' ? 'active' : '') 50 | a(href="/r/" + subreddit + "/top") top 51 | li 52 | a(href="/r/" + subreddit + "/wiki") wiki 53 | #links.sr 54 | if sortby === 'top' || sortby === 'controversial' 55 | details 56 | summary 57 | if past === 'hour' 58 | span links from: past hour 59 | if past === 'day' 60 | span links from: past 24 hours 61 | if past === 'week' 62 | span links from: past week 63 | if past === 'month' 64 | span links from: past month 65 | if past === 'year' 66 | span links from: past year 67 | if past === 'all' 68 | span links from: all time 69 | ul 70 | li(class=past === 'hour' ? 'active' : '') 71 | a(href="?t=hour") past hour 72 | li(class=past === 'day' ? 'active' : '') 73 | a(href="?t=day") past 24 hours 74 | li(class=past === 'week' ? 'active' : '') 75 | a(href="?t=week") past week 76 | li(class=past === 'month' ? 'active' : '') 77 | a(href="?t=month") past month 78 | li(class=past === 'year' ? 'active' : '') 79 | a(href="?t=year") past year 80 | li(class=past === 'all' ? 'active' : '') 81 | a(href="?t=all") all time 82 | if json.links.length === 0 83 | .reddit-error 84 | p This subreddit either doesn't exist, or any posts weren't found. 85 | else 86 | each link in json.links 87 | .link 88 | .upvotes 89 | .arrow 90 | span #{kFormatter(link.ups)} 91 | .arrow.down 92 | .image 93 | if link.images 94 | if link.is_self_link 95 | a(href="" + link.permalink + "") 96 | img(src="" + link.images.thumb + "", alt="") 97 | else 98 | a(href=""+ link.url +"", rel="noopener noreferrer") 99 | img(src="" + link.images.thumb + "", alt="") 100 | else 101 | a(href="" + link.permalink + "") 102 | .no-image no image 103 | .entry 104 | .title 105 | if link.is_self_link 106 | a(href="" + link.permalink + "") 107 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 108 | != link.link_flair 109 | span (#{link.domain}) 110 | else 111 | a(href="" + link.url + "", rel="noopener noreferrer") 112 | h2(class="" + (link.stickied ? 'green' : '') + "") #{cleanTitle(link.title)} 113 | != link.link_flair 114 | span (#{link.domain}) 115 | .meta 116 | p.submitted submitted 117 | span(title="" + toUTCString(link.created) + "") #{timeDifference(link.created)} by 118 | if link.author === '[deleted]' 119 | span(class="deleted") [deleted] 120 | else 121 | a(href="/u/" + link.author + "") 122 | | #{link.author} 123 | != link.user_flair 124 | p.to to 125 | a(href="/r/" + link.subreddit + "") 126 | | #{link.subreddit} 127 | if link.stickied 128 | span(class="green") stickied 129 | .links 130 | if link.over_18 131 | span.tag.nsfw NSFW 132 | if link.selftext_html 133 | details 134 | summary 135 | .line 136 | .line 137 | .line 138 | .selftext 139 | != unescape(link.selftext_html, user_preferences) 140 | if (link.images && link.images.preview) 141 | style. 142 | details.preview-container img { 143 | width: 100% !important; 144 | height: auto !important; 145 | max-width: none !important; 146 | max-height: none !important; 147 | opacity: 0; 148 | } 149 | details.preview-container[open][data-url="#{link.images.preview}"] .preview { 150 | width: 100%; 151 | height: auto; 152 | background-image: url('#{link.images.preview}'); 153 | background-repeat: no-repeat; 154 | background-size: contain; 155 | } 156 | details.preview-container(data-url="" + link.images.preview + "") 157 | summary 158 | span ▶ 159 | .preview 160 | img(src=""+ link.images.thumb +"", alt="") 161 | a(href="" + link.permalink + "", class="comments") #{link.num_comments} comments 162 | - 163 | let back_url = "/r/" + subreddit + "/" + sortby + "§2t="+ (past ? past : '') +"" 164 | if(before && !subreddit_front) 165 | back_url = "/r/" + subreddit + "/" + sortby + "§2t="+ (past ? past : '') +"§1before=" + before + "" 166 | if(after) 167 | back_url = "/r/" + subreddit + "/" + sortby + "§2t=" + (past ? past : '') + "§1after=" + after + "" 168 | - let saved_post = false 169 | if user_preferences.saved 170 | each post_id in user_preferences.saved 171 | if post_id === link.id 172 | - saved_post = true 173 | if saved_post 174 | a(href="/unsave/" + link.id + "/?rk=" + redis_key + "&b=" + back_url + "") unsave 175 | else 176 | a(href="/save/" + link.id + "/?rk=" + redis_key + "&b=" + back_url + "") save 177 | if json.info.before || json.info.after 178 | .view-more-links 179 | if json.info.before && !subreddit_front 180 | a(href="/r/" + subreddit + "/" + sortby + "?t="+ (past ? past : '') +"&before=" + json.info.before + "") ‹ prev 181 | if json.info.after 182 | a(href="/r/" + subreddit + "/" + sortby + "?t=" + (past ? past : '') + "&after=" + json.info.after + "") next › 183 | #sidebar 184 | #search.sr 185 | form(action="/r/" + subreddit + "/search", method="GET") 186 | div 187 | label(for="q") search 188 | input(type="text", name="q", id="q", placeholder="search") 189 | div 190 | label(for="restrict_sr") limit my search to r/#{subreddit} 191 | input(type="checkbox", name="restrict_sr", id="restrict_sr", checked="checked") 192 | div 193 | label(for="nsfw") include NSFW results 194 | input(type="checkbox", name="nsfw", id="nsfw", checked="checked") 195 | input(type="submit", value="search") 196 | if subreddit_about 197 | .subscribe.content 198 | - 199 | let subbed_to_this_subreddit = false 200 | let subbed = [] 201 | if(user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits)) 202 | subbed = user_preferences.subbed_subreddits 203 | for(let i = 0; i < subbed.length; i++) { 204 | if(subbed[i] === subreddit) 205 | subbed_to_this_subreddit = true 206 | } 207 | if subbed_to_this_subreddit 208 | a(href="/unsubscribe/" + subreddit + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 209 | else 210 | a(href="/subscribe/" + subreddit + "", class="sub-to-subreddit", title="subscriptions are saved in your browser's cookies") subscribe 211 | if subreddit_about.subscribers 212 | .content 213 | p subscribers: #{subreddit_about.subscribers.toLocaleString()} 214 | p users here right now: #{subreddit_about.active_user_count.toLocaleString()} 215 | br 216 | .heading 217 | p.title #{subreddit_about.title} 218 | .short-description 219 | != unescape(subreddit_about.public_description_html, user_preferences) 220 | .description 221 | != unescape(subreddit_about.description_html, user_preferences) 222 | if subreddit_about.moderators 223 | if subreddit_about.moderators.kind === 'UserList' 224 | if subreddit_about.moderators.data.children.length > 0 225 | .mod-list 226 | h4 Moderators 227 | ul 228 | each moderator in subreddit_about.moderators.data.children 229 | li 230 | a(href="/u/"+ moderator.name +"") 231 | p(title=""+ moderator.mod_permissions.join(', ') +"") #{moderator.name} 232 | span.flair #{moderator.author_flair_text} 233 | else 234 | if subreddit.includes('+') 235 | .content 236 | p These subreddits 237 | - 238 | let subreddits = subreddit.split('+') 239 | ul(class="subreddit-listing") 240 | each subreddit in subreddits 241 | - 242 | let subbed_to_this_subreddit = false 243 | if(user_preferences.subbed_subreddits) { 244 | let subbed = user_preferences.subbed_subreddits 245 | for(let i = 0; i < subbed.length; i++) { 246 | if(subbed[i] === subreddit) 247 | subbed_to_this_subreddit = true 248 | } 249 | } 250 | li 251 | if subbed_to_this_subreddit 252 | a(href="/unsubscribe/" + subreddit + "?b=/r/" + subreddits + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 253 | a(href="/r/" + subreddit + "") #{subreddit} 254 | else 255 | a(href="/subscribe/" + subreddit + "?b=/r/" + subreddits + "", class="sub-to-subreddit", title="subscriptions are saved in your browser's cookies") subscribe 256 | a(href="/r/" + subreddit + "") #{subreddit} 257 | - joined_subreddits = subreddits.join("+") 258 | a(href="/import_subscriptions/" + joined_subreddits + "?b=/r/" + joined_subreddits) subscribe to all of these subreddits 259 | include includes/footer.pug 260 | -------------------------------------------------------------------------------- /views/subreddit_wiki.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title wiki /r/#{subreddit} 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if json === null 9 | h1 Error occured 10 | if error 11 | p Error: #{JSON.stringify(json.error_data)} 12 | else 13 | header 14 | a(href="/", class="main") 15 | h1 teddit 16 | .bottom 17 | a(href="/r/" + subreddit + "", class="subreddit") 18 | h2 #{subreddit} 19 | ul.tabmenu 20 | li 21 | a(href="/r/" + subreddit) hot 22 | li 23 | a(href="/r/" + subreddit + "/new") new 24 | li 25 | a(href="/r/" + subreddit + "/rising") rising 26 | li 27 | a(href="/r/" + subreddit + "/controversial") controversial 28 | li 29 | a(href="/r/" + subreddit + "/top") top 30 | li(class="active") 31 | a(href="/r/" + subreddit + "/wiki") wiki 32 | .wiki-page.wiki-content 33 | != content_html 34 | include includes/footer.pug 35 | -------------------------------------------------------------------------------- /views/subreddits_explore.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title subreddits - explore 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if json === null 9 | h1 Error occured 10 | p Error: #{JSON.stringify(json.error_data)} 11 | else 12 | header 13 | a(href="/", class="main") 14 | h1 teddit 15 | .bottom 16 | a(href="/subreddits", class="subreddit") 17 | h2 subreddits - explore 18 | ul.tabmenu 19 | li(class=!sortby || sortby == 'hot' ? 'active' : '') 20 | a(href="/subreddits") popular 21 | li(class=sortby === 'new' ? 'active' : '') 22 | a(href="/subreddits/new") new 23 | #search.explore 24 | form(action="/subreddits/search", method="GET") 25 | div 26 | label(for="q") search subreddits 27 | input(type="text", name="q", id="q", value="" + (q ? q : '') + "", placeholder="search") 28 | div 29 | label(for="nsfw") include NSFW results 30 | if nsfw === 'on' 31 | input(type="checkbox", name="nsfw", id="nsfw", checked="checked") 32 | else 33 | input(type="checkbox", name="nsfw", id="nsfw") 34 | input(type="submit", value="search") 35 | #links.sr.explore 36 | if json.links.length === 0 37 | .reddit-error 38 | p This subreddit either doesn't exist, or any posts weren't found. 39 | else 40 | .infobar.explore 41 | p click the subscribe or unsubscribe buttons to choose which subreddits appear on the home feed. 42 | each link in json.links 43 | .link 44 | .entry 45 | - 46 | let subbed_to_this_subreddit = false 47 | let subbed = [] 48 | if(user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits)) 49 | subbed = user_preferences.subbed_subreddits 50 | for(let i = 0; i < subbed.length; i++) { 51 | if(subbed[i].toLowerCase() === link.display_name.toLowerCase()) 52 | subbed_to_this_subreddit = true 53 | } 54 | .sub-button 55 | if subbed_to_this_subreddit 56 | if !searching 57 | a(href="/unsubscribe/" + link.display_name + "?b=/subreddits/" + sortby + "?after=" + after + "§1before=" + before + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 58 | else 59 | a(href="/unsubscribe/" + link.display_name + "?b=/subreddits/search?q=" + q + "§1nsfw=" + nsfw + "§1after=" + after + "§1before=" + before + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 60 | else 61 | if !searching 62 | a(href="/subscribe/" + link.display_name + "?b=/subreddits/" + sortby + "?after=" + after + "§1before=" + before + "", class="sub-to-subreddit", title="subscriptions are saved in your browser's cookies") subscribe 63 | else 64 | a(href="/subscribe/" + link.display_name + "?b=/subreddits/search?q=" + q + "§1nsfw=" + nsfw + "§1after=" + after + "§1before=" + before + "", class="sub-to-subreddit", title="subscriptions are saved in your browser's cookies") subscribe 65 | .content 66 | .title 67 | a(href="" + link.url + "", rel="noopener noreferrer") 68 | h2 #{link.display_name_prefixed}: #{cleanTitle(link.title)} 69 | .description 70 | p #{cleanTitle(link.public_description)} 71 | .meta 72 | p.subscribers #{kFormatter(link.subscribers)} subscribers, 73 | p.submitted   created 74 | span(title="" + toUTCString(link.created) + "") #{timeDifference(link.created)} 75 | .links 76 | if link.over_18 77 | span.tag.nsfw NSFW 78 | if json.info.before || json.info.after 79 | .view-more-links 80 | if json.info.before && !subreddits_front 81 | a(href="/subreddits/" + sortby + "?before=" + json.info.before + "&nsfw=" + nsfw + "&q=" + (q ? q : '') + "") ‹ prev 82 | if json.info.after 83 | a(href="/subreddits/" + sortby + "?after=" + json.info.after + "&nsfw=" + nsfw + "&q=" + (q ? q : '') + "") next › 84 | #sidebar 85 | .content 86 | if user_preferences.subbed_subreddits && Array.isArray(user_preferences.subbed_subreddits) 87 | p your subscribed subreddits 88 | ul.subreddit-listing 89 | each subreddit in user_preferences.subbed_subreddits 90 | li 91 | if !searching 92 | a(href="/unsubscribe/" + subreddit + "?b=/subreddits/" + sortby + "?after=" + after + "§1before=" + before + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 93 | a(href="/r/" + subreddit) #{subreddit} 94 | else 95 | a(href="/unsubscribe/" + subreddit + "?b=/subreddits/search?q=" + q + "§1nsfw=" + nsfw + "§1after=" + after + "§1before=" + before + "", class="sub-to-subreddit gray", title="subscriptions are saved in your browser's cookies") unsubscribe 96 | a(href="/r/" + subreddit) #{subreddit} 97 | include includes/footer.pug 98 | -------------------------------------------------------------------------------- /views/user.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title overview for #{data.username} 5 | include includes/head.pug 6 | body(class=""+ (user_preferences.theme === 'auto' ? 'dark' : user_preferences.theme) + "") 7 | include includes/topbar.pug 8 | if user === null 9 | h1 Error occured 10 | p #{JSON.stringify(error_data)} 11 | else 12 | #user 13 | header 14 | .bottom 15 | a(href="/u/" + data.username + "") 16 | h3.username user: #{data.username} 17 | ul.tabmenu 18 | li(class=!data.post_type || data.post_type == '' ? 'active' : '') 19 | a(href="/u/" + data.username) overview 20 | li(class=data.post_type === '/comments' ? 'active' : '') 21 | a(href="/u/" + data.username + "/comments") comments 22 | li(class=data.post_type === '/submitted' ? 'active' : '') 23 | a(href="/u/" + data.username + "/submitted") submitted 24 | #links 25 | details 26 | summary 27 | if !sortby || sortby === 'new' 28 | span sorted by: new 29 | if sortby === 'hot' 30 | span sorted by: hot 31 | if sortby === 'top' 32 | span sorted by: top 33 | if sortby === 'controversial' 34 | span sorted by: controversial 35 | ul 36 | li(class=!sortby || sortby == 'new' ? 'active' : '') 37 | a(href="/u/" + data.username + data.post_type) new 38 | li(class=sortby === 'hot' ? 'active' : '') 39 | a(href="/u/" + data.username + data.post_type + "?sort=hot") hot 40 | li(class=sortby === 'top' ? 'active' : '') 41 | a(href="/u/" + data.username + data.post_type + "?sort=top&t=" + past + "") top 42 | li(class=sortby === 'controversial' ? 'active' : '') 43 | a(href="/u/" + data.username + data.post_type + "?sort=controversial&t=" + past + "") controversial 44 | if sortby === 'top' || sortby === 'controversial' 45 | details 46 | summary 47 | if past === 'all' 48 | span links and comments from: all time 49 | if past === 'hour' 50 | span links and comments from: past hour 51 | if past === 'day' 52 | span links and comments from: past 24 hours 53 | if past === 'week' 54 | span links and comments from: past week 55 | if past === 'month' 56 | span links and comments from: past month 57 | if past === 'year' 58 | span links and comments from: past year 59 | ul 60 | li(class=past === 'hour' ? 'active' : '') 61 | a(href="?sort=" + sortby + "&t=hour") past hour 62 | li(class=past === 'day' ? 'active' : '') 63 | a(href="?sort=" + sortby + "&t=day") past 24 hours 64 | li(class=past === 'week' ? 'active' : '') 65 | a(href="?sort=" + sortby + "&t=week") past week 66 | li(class=past === 'month' ? 'active' : '') 67 | a(href="?sort=" + sortby + "&t=month") past month 68 | li(class=past === 'year' ? 'active' : '') 69 | a(href="?sort=" + sortby + "&t=year") past year 70 | li(class=past === 'all' ? 'active' : '') 71 | a(href="?sort=" + sortby + "&t=all") all time 72 | .info 73 | img(src=data.icon_img) 74 | h1 #{data.username} 75 | p(class="user-stat") #{kFormatter(data.link_karma)} post karma 76 | p(class="user-stat") #{kFormatter(data.comment_karma)} comment karma 77 | br 78 | p(title="" + toUTCString(data.created) + "") account created: #{toDateString(data.created)} 79 | p verified: #{(data.verified) ? "yes" : "no" } 80 | .entries 81 | if !data.posts || data.posts.length <= 0 82 | h3 no posts/comments 83 | each post in data.posts 84 | if post.type === 't3' 85 | .entry.t3 86 | .upvotes 87 | .arrow 88 | span #{kFormatter(post.ups)} 89 | .arrow.down 90 | .image 91 | if post.thumbnail !== 'self' 92 | a(href="" + post.permalink + "", rel="noopener noreferrer") 93 | img(src="" + post.thumbnail + "", alt="") 94 | if post.duration 95 | span #{secondsToMMSS(post.duration)} 96 | else 97 | a(href="" + post.permalink + "", rel="noopener noreferrer") 98 | .no-image no image 99 | .title 100 | a(href="" + post.permalink + "", rel="noopener noreferrer") #{cleanTitle(post.title)} 101 | .meta 102 | p.submitted(title="" + toUTCString(post.created) + "") submitted #{timeDifference(post.created)} 103 | | by 104 | a(href="/u/" + data.username + "") #{data.username} 105 | | to 106 | != post.user_flair 107 | a(href="/r/" + post.subreddit + "", class="subreddit") #{post.subreddit} 108 | if post.over_18 109 | span.tag.nsfw NSFW 110 | a.comments(href="" + post.permalink + "") #{post.num_comments} comments 111 | if post.type === 't1' 112 | .entry 113 | .meta 114 | .title 115 | a(href="" + post.url + "", rel="noopener noreferrer") #{cleanTitle(post.link_title)} 116 | .author 117 | p by 118 | if post.link_author === '[deleted]' 119 | | [deleted] 120 | else 121 | a(href="/u/" + post.link_author + "") #{post.link_author} 122 | .subreddit 123 | p in 124 | a(href="/r/" + post.subreddit + "") #{post.subreddit} 125 | .comment 126 | details(open="") 127 | summary 128 | a(href="/u/" + data.username + "") #{data.username} 129 | p.ups #{post.ups} points 130 | p.created(title="" + toUTCString(post.created) + "") #{timeDifference(post.created)} 131 | .meta 132 | p.author 133 | a(href="/u/" + data.username + "") #{data.username} 134 | p 135 | != post.user_flair 136 | p.ups #{post.ups} points 137 | p.created(title="" + toUTCString(post.created) + "") #{timeDifference(post.created)} 138 | .body 139 | div !{post.body_html} 140 | if post.over_18 141 | span.tag.nsfw NSFW 142 | a.context(href="" + post.permalink + "?context=10") context 143 | a.comments.t1(href="" + post.url + "") full comments (#{post.num_comments}) 144 | if data.before || data.after 145 | p view more: 146 | if data.before && !data.user_front 147 | a(href="/u/" + data.username + data.post_type + "?before=" + data.before + "") ‹ prev 148 | if data.after 149 | a(href="/u/" + data.username + data.post_type + "?after=" + data.after + "") next › 150 | include includes/footer.pug 151 | --------------------------------------------------------------------------------