├── .gitignore ├── .jshintrc ├── LICENSE.txt ├── README.md ├── comments.js ├── config.js ├── helpers.js ├── index.js ├── package.json ├── scripts ├── load_content.js ├── query.js ├── reset.js └── stats.js └── views ├── alive.css.ect ├── alive.ect └── dead.ect /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | local/ 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent": 2, 3 | "node": true, 4 | "enforceall": true, 5 | "nocomma": false 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2015 Matthew Rothenberg 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unindexed 2 | > A website that irrevocably deletes itself once indexed by Google. 3 | 4 | The site is constantly searching for itself in Google, over and over and over, 24 hours a day. The instant it finds itself in Google search results, the site will instantaneously and irrevocably securely delete itself. Visitors can contribute to the public content of the site, these contributions will also be destroyed when the site deletes itself. 5 | 6 | **Why would you do such a thing?** My full explanation was in the content of the site. 7 | _(edit: ...which is now gone)_ 8 | 9 | ![card](http://f.cl.ly/items/0Q3t120C0f1f3N1Q0416/unindexed_invite_blurred_sm.jpg) 10 | 11 | **UPDATE: The experiment lasted 22 days before it was indexed by Google on 24 February 2015 at 21:01:14 and instantaneously destroyed. It was primarily shared via physical means in the real world, word of mouth, etc.** 12 | 13 | If you didn't find it before it went away, you can see some of my other projects on [my portfolio](http://portfolio.mroth.info), 14 | or [maybe just watch this instead.](https://www.youtube.com/watch?v=qqmNaOn56mc) 15 | 16 | If you want to conduct your own similar experiment, the source code is here. 17 | 18 | ## info 19 | 20 | - Nothing has been done to prevent the site from being indexed, however the 21 | NOARCHIVE meta tag is specified which instructs Google not to cache 22 | their own copy of the content. 23 | 24 | - The content for this site is stored in memory only (via Redis) and is loaded 25 | in via a file from an encrypted partition on my personal laptop. This 26 | partition is then destroyed immediately after launching the site. Redis 27 | backups are disabled. The content is flushed from memory once the site 28 | detects that it has been indexed. 29 | 30 | - The URL of the site can be algorithmically generated and is configured via 31 | environment variable, so this source code can be made public without 32 | disclosing the location of the site to bots. 33 | 34 | - Visitors can leave comments on the site while it is active. These comments 35 | are similarly flushed along with the rest of the content upon index event, 36 | making them equally ephemeral. 37 | 38 | ## other 39 | 40 | Sample configuration notes for running on Heroku: 41 | 42 | $ heroku create `pwgen -AnB 6 1` # generates a random hostname 43 | $ heroku addons:add rediscloud # default free tier disables backups 44 | $ heroku config:set REDIS_URL=`heroku config:get REDISCLOUD_URL` 45 | $ heroku config:set SITE_URL=`heroku domains | sed -ne "2,2p;2q"` 46 | $ git push heroku master 47 | $ heroku run npm run reset 48 | $ heroku addons:add scheduler:standard 49 | $ heroku addons:open scheduler 50 | 51 | Schedule a task every N minutes for `npm run-script query` (unfortunately seems 52 | like this can only be done via web interface). 53 | 54 | Use `scripts/load_content.js` to load the content piped from STDIN. 55 | 56 | You can configure monitoring to check the `/status` endpoint for `"OK"` if you 57 | trust an external service with your URL. 58 | -------------------------------------------------------------------------------- /comments.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // form processing and sanitization 4 | var sanitizeHtml = require('sanitize-html'); 5 | var bodyParser = require('body-parser'); 6 | var urlencodedParser = bodyParser.urlencoded({ extended: false }); 7 | 8 | // configure markdown processsing 9 | // override heading function to allow only a subset of syntax 10 | var marked = require('marked'); 11 | var renderer = new marked.Renderer(); 12 | renderer.heading = function (text, level) { return text; }; 13 | 14 | marked.setOptions({ 15 | renderer: renderer, 16 | gfm: true, 17 | tables: false, 18 | breaks: true, 19 | pedantic: false, 20 | sanitize: false, //doing manually to allow some basic HTML 21 | smartLists: true, 22 | smartypants: true 23 | }); 24 | 25 | 26 | module.exports = function (app, client) { 27 | // comment submission 28 | app.post('/comments', urlencodedParser, function (req, res) { 29 | 30 | // no blanks 31 | if (!req.body) { return res.sendStatus(400); } 32 | 33 | // strip nasty stuff 34 | var user = sanitizeHtml(req.body.user, { 35 | allowedTags: [] 36 | }).trim(); 37 | var comment = sanitizeHtml(req.body.comment, { 38 | allowedTags: [ 'b', 'i', 'em', 'strong', 'a' ], 39 | allowedAttributes: { 40 | 'a': [ 'href' ] 41 | } 42 | }).trim(); 43 | 44 | // if no content is left after stripping, error out 45 | if (comment === "") { 46 | return res.status(400).send("Bad request. Did you fill everything out?"); 47 | } 48 | 49 | // no username is anonymous 50 | if (user === "") { user = "anonymous"; } 51 | 52 | // truncate at max length for fields (plus a little for leniency) 53 | user = user.slice(0,48+8); 54 | comment = comment.slice(0,2048+8); 55 | 56 | // process markdown for post 57 | comment = marked(comment); 58 | 59 | // create post json 60 | var post = JSON.stringify({ 61 | ip: req.headers['X-Forwarded-For'] || req.connection.remoteAddress, 62 | t: (new Date()).toJSON(), 63 | u: user, 64 | c: comment 65 | }); 66 | 67 | // post to redis 68 | client.multi() 69 | .rpush("comments", post) 70 | .incr("comments_count") 71 | .exec(); 72 | 73 | // redirect to original page, at bottom 74 | res.redirect("/#comments-latest"); 75 | }); 76 | 77 | }; 78 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | if (!process.env.DYNO) { 4 | console.log("[We don't seem to be on Heroku, doing a manual dotenv load...]"); 5 | var dotenv = require('dotenv'); 6 | dotenv.load(); 7 | } 8 | 9 | module.exports = { 10 | site: { 11 | name: function() { return process.env.SITE_NAME; }, 12 | url: function() { return process.env.SITE_URL; } 13 | }, 14 | 15 | redis: { 16 | client: function() { 17 | return require('redis-url').connect(); 18 | } 19 | } 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | 5 | // convenience function to calculate time site has been alive as string 6 | timeAlive: function (start, end) { 7 | var diff = (end - start) / 1000 / 60; // in minutes 8 | if (diff < 120) { 9 | return Math.ceil(diff) + " minutes"; 10 | } else if (diff < 72*60) { 11 | return Math.ceil(diff/60) + " hours"; 12 | } else { 13 | return Math.ceil(diff/60/24) + " days"; 14 | } 15 | } 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var helpers = require('./helpers'); 4 | var config = require('./config'); 5 | var client = config.redis.client(); 6 | 7 | var express = require('express'); 8 | var app = express(); 9 | 10 | // templating engine stuff (ugh) 11 | var ECT = require('ect'); 12 | var ectRendererOptions = { 13 | watch: false, 14 | root: __dirname + '/views', 15 | ext : '.ect', 16 | gzip: true 17 | }; 18 | var ectRenderer = ECT(ectRendererOptions); 19 | app.set('view engine', 'ect'); 20 | app.engine('ect', ectRenderer.render); 21 | 22 | // main route 23 | app.get('/', function (req, res) { 24 | 25 | var queryKeys = [ "times_indexed", 26 | "created_at", 27 | "destroyed_at", 28 | "live_visitors", 29 | "content", 30 | "comments_count" ]; 31 | 32 | client.multi() 33 | .mget(queryKeys) 34 | .lrange("comments", 0, -1) 35 | .exec( function (err,reply) { 36 | 37 | if (err) { 38 | res.send("Oh dear. Something went horribly wrong."); 39 | console.log("Horrific redis load error on page request: " + err); 40 | } else { 41 | var queryVal = reply[0]; 42 | var comments = reply[1].map(JSON.parse); 43 | 44 | var timesIndexed = parseInt(queryVal[0], 10); 45 | var siteIsAlive = timesIndexed === 0; 46 | var createdAt = new Date(queryVal[1]); 47 | var destroyedAt = queryVal[2] ? new Date(queryVal[2]) : new Date(); 48 | 49 | var data = { 50 | timesIndexed: timesIndexed, 51 | createdAt: createdAt, 52 | destroyedAt: destroyedAt, 53 | timeAlive: helpers.timeAlive(createdAt, destroyedAt), 54 | liveVisitors: queryVal[3], 55 | content: queryVal[4], 56 | commentsCount: queryVal[5], 57 | comments: comments 58 | }; 59 | 60 | if (siteIsAlive) { 61 | // display alive template 62 | res.render('alive', data); 63 | // update live_visitors count 64 | client.incr("live_visitors"); 65 | } else { 66 | // header GONE 67 | res.status(410); 68 | // display a text status message with stats 69 | res.type("text"); 70 | res.render('dead', data); 71 | // update dead_visitors count 72 | client.incr("dead_visitors"); 73 | } 74 | } 75 | 76 | }); 77 | }); 78 | 79 | // load comment processing routes from separate module 80 | require('./comments')(app, client); 81 | 82 | // allow all robot overlords (this is default, but more fun to be specific) 83 | app.get('/robots.txt', function (req, res) { 84 | res.type("text"); 85 | res.write("User-agent: ia_archiver\nDisallow: /\n\n"); 86 | res.write("User-agent: *\nDisallow:\n"); 87 | res.end(); 88 | }); 89 | 90 | // for status pings 91 | app.get('/status', function (req, res) { 92 | res.type("text"); 93 | res.send("OK"); 94 | }); 95 | 96 | var port = process.env.PORT || 3000; 97 | console.log("Starting up on port " + port); 98 | app.listen(port); 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unindexed", 3 | "version": "0.0.1", 4 | "description": "A website the deletes itself once indexed by Google.", 5 | "private": true, 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "lint": "jshint *.js scripts/*.js", 10 | "start": "node index.js", 11 | "query": "node scripts/query.js", 12 | "stats": "node scripts/stats.js", 13 | "reset": "node scripts/reset.js" 14 | }, 15 | "author": "Matthew Rothenberg ", 16 | "license": "WTFPL", 17 | "dependencies": { 18 | "body-parser": "^1.10.1", 19 | "ect": "^0.5.9", 20 | "express": "^4.10.7", 21 | "google": "^0.6.0", 22 | "marked": "^0.3.2", 23 | "redis": "^0.12.1", 24 | "redis-url": "^0.3.1", 25 | "sanitize-html": "^1.5.1" 26 | }, 27 | "devDependencies": { 28 | "dotenv": "^0.4.0" 29 | }, 30 | "engines": { 31 | "node": "0.10.x" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /scripts/load_content.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Takes whatever is sent to STDIN, and loads it into the content field in Redis. 4 | 5 | Takes the REDIS_URL as optional argument 6 | 7 | Usage examples: 8 | - node load_content.js redis://localhost:6379 < /path/to/content.txt 9 | - node load_content.js `heroku config:get REDIS_URL` < /path/to/content.txt 10 | 11 | */ 12 | 13 | "use strict"; 14 | 15 | var redisurl = require('redis-url'); 16 | var client; 17 | if (process.argv[2]) { 18 | client = redisurl.connect(process.argv[2]); 19 | } else { 20 | client = redisurl.connect(); 21 | } 22 | 23 | // take whatever is sent to stdin, and load it into content field 24 | var content = ''; 25 | process.stdin.resume(); 26 | process.stdin.on('data', function (buf) { content += buf.toString(); }); 27 | process.stdin.on('end', function () { 28 | client.set(['content', content], function (err, reply) { 29 | console.log("set content!"); 30 | 31 | client.get('content', function (err, res) { 32 | console.log("checking content, appears to be..."); 33 | console.log(res); 34 | client.quit(); 35 | }); 36 | 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /scripts/query.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Queries for the site in Google search index, updates redis with results. 4 | 5 | This should be run periodically from an automated cron task or whatnot. 6 | 7 | This can run forever, but times_mentioned null->1 is special case so we have to 8 | record time-of-death when that happens. 9 | */ 10 | "use strict"; 11 | 12 | var config = require('../config'); 13 | var siteUrl = config.site.url(); 14 | 15 | var google = require('google'); 16 | google.resultsPerPage = 100; 17 | 18 | // site:siteUrl ? 19 | // "siteUrl" in quotes for goog exact search? 20 | // var query = "\"" + siteUrl + "\""; 21 | var query = "site:" + siteUrl; 22 | console.log("Querying google for [" + query + "]..."); 23 | 24 | google(query, function (err, next, links) { 25 | if (err) { 26 | console.log("*** got an error!: " + err); 27 | process.exit(1); 28 | } else { 29 | var n = links.length; 30 | console.log(" ...got " + n + " search results."); 31 | 32 | if (n > 0) { 33 | var client = config.redis.client(); 34 | // if the previous times indexed was null, this is the first time 35 | // we were indexed! so we need to record the historic moment. 36 | // instead doing a query/response, let's be clever and use SETNX. 37 | client.multi() 38 | .set(["times_indexed", n]) 39 | .del("content") 40 | .del("comments") 41 | .setnx(["destroyed_at", (new Date()).toString()]) 42 | .exec(function (err, replies) { 43 | 44 | if (err) { 45 | console.log("*** Error updating redis!"); 46 | process.exit(1); 47 | } 48 | 49 | console.log("-> Set times_indexed to " + n + ":\t\t" + replies[0]); 50 | console.log("-> Did we just destroy content?:\t" + replies[1]); 51 | console.log("-> Did we just destroy comments?:\t" + replies[2]); 52 | console.log("-> Did we initialize destroyed_at?:\t" + replies[3]); 53 | client.quit(); 54 | }); 55 | } 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /scripts/reset.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Zeroes out all control variables to pristine state. Doesn't affect content. 4 | 5 | NOTE: I AM DANGEROUS - ONLY FOR DEV MODE TESTING. 6 | */ 7 | 8 | "use strict"; 9 | 10 | var config = require('../config'); 11 | var client = config.redis.client(); 12 | 13 | client.multi() 14 | .set("times_indexed", 0) // zero index count 15 | .set("live_visitors", 0) // zero both visitor counts 16 | .set("dead_visitors", 0) // 17 | .del("destroyed_at") // null out destroyed_at 18 | .del("comments") // get rid of all comments 19 | .del("comments_count") // 20 | .set("created_at", (new Date()).toString()) // set created_at to Now 21 | .exec(function (err, replies) { 22 | if (err) { 23 | console.log("*** Error updating redis!"); 24 | process.exit(1); 25 | } 26 | 27 | console.log(replies); 28 | client.quit(); 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/stats.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Dump current Redis status to screen, for checking stats without visiting site. 4 | 5 | */ 6 | "use strict"; 7 | 8 | var config = require('../config'); 9 | var client = config.redis.client(); 10 | 11 | var keys = [ 12 | "times_indexed", 13 | "comments_count", 14 | "live_visitors", 15 | "dead_visitors", 16 | "created_at", 17 | "destroyed_at" 18 | ]; 19 | 20 | client.mget(keys, function (err, replies) { 21 | if (err) { 22 | console.log("*** Error querying redis!"); 23 | process.exit(1); 24 | } 25 | 26 | replies.forEach( function(r, i) { 27 | console.log(keys[i] + ":\t" + r); 28 | }); 29 | client.quit(); 30 | } 31 | ); 32 | -------------------------------------------------------------------------------- /views/alive.css.ect: -------------------------------------------------------------------------------- 1 | 56 | -------------------------------------------------------------------------------- /views/alive.ect: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <% include 'alive.css' %> 10 | 11 | 12 |
13 | 14 |
15 | <%- @content %> 16 |
17 | 18 |
19 |

20 | This site was launched <%= @timeAlive %> ago 21 | at <%= @createdAt %> 22 | and has been visited <%= @liveVisitors %> times since then. 23 | 24 | Current times it appears in the Google search index: 25 | <%= @timesIndexed %>. 26 |

27 | 28 |
29 | 30 |

31 | Comments will be truncated beyond 2048 characters. 32 | When the site goes, they all go with it. 33 |

34 |
35 | 36 | 37 |
    38 | <% for comment in @comments : %> 39 |
  • 40 |
    41 | <%- comment.c %> 42 |
    43 |
    44 | <%= comment.u %> @<%= comment.t %> 45 |
    46 |
  • 47 | <% end %> 48 |
49 | 50 | 51 | 52 |
53 |
54 |
60 | 61 |
62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /views/dead.ect: -------------------------------------------------------------------------------- 1 | HTTP/1.1 410 Gone 2 | 3 | This web site is no longer here. 4 | It was automatically and permanently deleted after being indexed by Google. 5 | Prior to its deletion on <%= @destroyedAt %> it was active for <%= @timeAlive %> and viewed <%= @liveVisitors %> times. 6 | <%= @commentsCount %> of those visitors had added to the conversation. 7 | --------------------------------------------------------------------------------