Merge branch 'release/api_key'
This commit is contained in:
commit
28a35064a7
12
README.md
12
README.md
@ -12,18 +12,21 @@ core requirements
|
||||
-------------
|
||||
* postgres
|
||||
* redis
|
||||
* node v0.4.8+
|
||||
* node >v0.4.8 && < 0.7.0>
|
||||
* npm
|
||||
|
||||
usage
|
||||
-----
|
||||
|
||||
Edit config/environments/<environment>.js
|
||||
Make sure redis is running and knows about active cartodb user.
|
||||
|
||||
``` bash
|
||||
node [cluster.js|app.js] [developement|test|production]
|
||||
node [cluster.js|app.js] <environment>
|
||||
```
|
||||
|
||||
Supported <environment> values are developement, test, production
|
||||
|
||||
for examples of use, see /tests
|
||||
|
||||
|
||||
@ -38,3 +41,8 @@ npm install
|
||||
tests
|
||||
------
|
||||
see test/README.md
|
||||
|
||||
|
||||
note on 0.4.x
|
||||
--------------
|
||||
output of large result sets is slow under node 0.4. Recommend running under 0.6 where possible.
|
@ -19,10 +19,11 @@ var express= require('express')
|
||||
buffer: true,
|
||||
format: '[:date] :req[X-Real-IP] \033[90m:method\033[0m \033[36m:req[Host]:url\033[0m \033[90m:status :response-time ms -> :res[Content-Type]\033[0m'
|
||||
}))
|
||||
, Step = require('step')
|
||||
, Meta = require(global.settings.app_root + '/app/models/metadata')
|
||||
, oAuth = require(global.settings.app_root + '/app/models/oauth')
|
||||
, PSQL = require(global.settings.app_root + '/app/models/psql')
|
||||
, Step = require('step')
|
||||
, Meta = require(global.settings.app_root + '/app/models/metadata')
|
||||
, oAuth = require(global.settings.app_root + '/app/models/oauth')
|
||||
, PSQL = require(global.settings.app_root + '/app/models/psql')
|
||||
, ApiKeyAuth = require(global.settings.app_root + '/app/models/apikey_auth')
|
||||
, _ = require('underscore');
|
||||
|
||||
app.use(express.bodyParser());
|
||||
@ -35,6 +36,7 @@ function handleQuery(req, res){
|
||||
// sanitize input
|
||||
var body = (req.body) ? req.body : {};
|
||||
var sql = req.query.q || body.q; // get and post
|
||||
var api_key = req.query.api_key || body.api_key;
|
||||
var database = req.query.database; // deprecate this in future
|
||||
var limit = parseInt(req.query.rows_per_page);
|
||||
var offset = parseInt(req.query.page);
|
||||
@ -68,7 +70,11 @@ function handleQuery(req, res){
|
||||
function setDBGetUser(err, data) {
|
||||
if (err) throw err;
|
||||
database = (data == "" || _.isNull(data)) ? database : data;
|
||||
oAuth.verifyRequest(req, this);
|
||||
if(api_key) {
|
||||
ApiKeyAuth.verifyRequest(req, this);
|
||||
} else {
|
||||
oAuth.verifyRequest(req, this);
|
||||
}
|
||||
},
|
||||
function querySql(err, user_id){
|
||||
if (err) throw err;
|
||||
|
114
app/models/apikey_auth.js
Normal file
114
app/models/apikey_auth.js
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* this module allows to auth user using an pregenerated api key
|
||||
*/
|
||||
|
||||
var RedisPool = require("./redis_pool")
|
||||
, _ = require('underscore')
|
||||
, Step = require('step');
|
||||
|
||||
module.exports = (function() {
|
||||
|
||||
var me = {
|
||||
user_metadata_db: 5,
|
||||
table_metadata_db: 0,
|
||||
user_key: "rails:users:<%= username %>",
|
||||
map_key: "rails:users:<%= username %>:map_key",
|
||||
table_key: "rails:<%= database_name %>:<%= table_name %>"
|
||||
};
|
||||
|
||||
me.retrieve = function(db, redisKey, hashKey, callback) {
|
||||
this.redisCmd(db,'HGET',[redisKey, hashKey], callback);
|
||||
};
|
||||
|
||||
me.inSet = function(db, setKey, member, callback) {
|
||||
this.redisCmd(db,'SISMEMBER',[setKey, member], callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use Redis
|
||||
*
|
||||
* @param db - redis database number
|
||||
* @param redisFunc - the redis function to execute
|
||||
* @param redisArgs - the arguments for the redis function in an array
|
||||
* @param callback - function to pass results too.
|
||||
*/
|
||||
me.redisCmd = function(db, redisFunc, redisArgs, callback) {
|
||||
|
||||
var redisClient;
|
||||
Step(
|
||||
function() {
|
||||
var step = this;
|
||||
RedisPool.acquire(db, function(_redisClient) {
|
||||
redisClient = _redisClient;
|
||||
redisArgs.push(step);
|
||||
redisClient[redisFunc.toUpperCase()].apply(redisClient, redisArgs);
|
||||
});
|
||||
},
|
||||
function releaseRedisClient(err, data) {
|
||||
if (err) throw err;
|
||||
RedisPool.release(db, redisClient);
|
||||
callback(err, data);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Get the user id for this particular subdomain/username
|
||||
*
|
||||
* @param req - standard express req object. importantly contains host information
|
||||
* @param callback
|
||||
*/
|
||||
me.getId = function(req, callback) {
|
||||
// strip subdomain from header host
|
||||
var username = req.headers.host.split('.')[0];
|
||||
var redisKey = _.template(this.user_key, {username: username});
|
||||
|
||||
this.retrieve(this.user_metadata_db, redisKey, 'id', callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the user map key for this particular subdomain/username
|
||||
*
|
||||
* @param req - standard express req object. importantly contains host information
|
||||
* @param callback
|
||||
*/
|
||||
me.checkAPIKey= function(req, callback) {
|
||||
// strip subdomain from header host
|
||||
var username = req.headers.host.split('.')[0];
|
||||
var redisKey = _.template(this.map_key, {username: username});
|
||||
var api_key = req.query.api_key || req.body.api_key;
|
||||
this.inSet(this.user_metadata_db, redisKey, api_key, callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get privacy for cartodb table
|
||||
*
|
||||
* @param req - standard req object. Importantly contains table and host information
|
||||
* @param callback - user_id if ok, null if auth fails
|
||||
*/
|
||||
me.verifyRequest = function(req, callback) {
|
||||
var that = this;
|
||||
|
||||
Step(
|
||||
// check api key
|
||||
function(){
|
||||
that.checkAPIKey(req, this);
|
||||
},
|
||||
// get user id or fail
|
||||
function (err, apikey_valid) {
|
||||
if (apikey_valid) {
|
||||
that.getId(req, this);
|
||||
} else {
|
||||
// no auth
|
||||
callback(false, null);
|
||||
}
|
||||
},
|
||||
function (err, user_id){
|
||||
if (err) throw err;
|
||||
callback(false, user_id);
|
||||
}
|
||||
);
|
||||
};
|
||||
return me;
|
||||
})();
|
@ -65,7 +65,7 @@ var oAuth = function(){
|
||||
me.verifyRequest = function(req, callback){
|
||||
var that = this;
|
||||
//TODO: review this
|
||||
var http = arguments['2'];
|
||||
var http = true;//arguments['2'];
|
||||
var passed_tokens;
|
||||
var ohash;
|
||||
var signature;
|
||||
|
12
package.json
12
package.json
@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"name": "cartodb_api",
|
||||
"description": "high speed SQL api for cartodb",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"author": {
|
||||
"name": "Simon Tokumine, Vizzuality",
|
||||
"url": "http://vizzuality.com",
|
||||
@ -10,14 +10,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cluster": "0.6.4",
|
||||
"express": "2.4.3",
|
||||
"express": "2.5.8",
|
||||
"underscore" : "1.1.x",
|
||||
"underscore.string": "1.1.5",
|
||||
"pg": "0.5.6",
|
||||
"pg": "0.6.14",
|
||||
"generic-pool": "1.0.x",
|
||||
"redis": "0.6.1",
|
||||
"redis": "0.7.1",
|
||||
"hiredis": "*",
|
||||
"step": "0.0.x",
|
||||
"oauth-client": "0.2.0"
|
||||
"oauth-client": "0.2.0",
|
||||
"node-uuid":"1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"expresso": "0.8.x"
|
||||
|
56
spike/app.js
Normal file
56
spike/app.js
Normal file
@ -0,0 +1,56 @@
|
||||
var express = require('express')
|
||||
, app = express.createServer(
|
||||
express.logger({
|
||||
buffer: true,
|
||||
format: '[:date] :req[X-Real-IP] \033[90m:method\033[0m \033[36m:req[Host]:url\033[0m \033[90m:status :response-time ms -> :res[Content-Type]\033[0m'
|
||||
}))
|
||||
, Step = require('step')
|
||||
, _ = require('underscore');
|
||||
|
||||
app.use(express.bodyParser());
|
||||
app.use(express.static(__dirname + '/public'));
|
||||
app.enable('jsonp callback');
|
||||
|
||||
var io = require('socket.io');
|
||||
io = io.listen(app);
|
||||
|
||||
io.configure('development', function(){
|
||||
io.set('log level', 1);
|
||||
io.set('origins', '*:*');
|
||||
});
|
||||
|
||||
app.listen(8080);
|
||||
|
||||
// hacked postgres setup
|
||||
//var pg = require('pg');
|
||||
var pg = require('pg').native //native libpq bindings = `
|
||||
var conString = "tcp://postgres@localhost/cartodb_dev_user_2_db";
|
||||
|
||||
var client = new pg.Client(conString);
|
||||
client.connect();
|
||||
|
||||
|
||||
io.sockets.on('connection', function (socket) {
|
||||
socket.emit('news', { hello: 'world' });
|
||||
socket.on('my other event', function (data) {
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
socket.on('sql_query', function(data){
|
||||
|
||||
var query = client.query(data.sql);
|
||||
var id = data.id;
|
||||
|
||||
query.on('row', function(row) {
|
||||
socket.emit("sql_result", {r:row, id:id, state:1})
|
||||
});
|
||||
|
||||
query.on('end',function(){
|
||||
socket.emit("sql_result", {id:id, state:0});
|
||||
});
|
||||
|
||||
query.on('error', function(row){
|
||||
socket.emit("sql_result", {r:row, id:id, state:-1})
|
||||
});
|
||||
});
|
||||
});
|
150
spike/public/gmaps_mercator.js
Normal file
150
spike/public/gmaps_mercator.js
Normal file
@ -0,0 +1,150 @@
|
||||
|
||||
function Point(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
/** return a copy of this point with coordinates as int */
|
||||
Point.prototype.floor = function() {
|
||||
return new Point(this.x>>0, this.y>>0);
|
||||
}
|
||||
|
||||
function LatLng(lat, lng) {
|
||||
this.lat = lat;
|
||||
this.lng = lng;
|
||||
}
|
||||
|
||||
LatLng.prototype.clone = function() {
|
||||
return new LatLng(this.lat, this.lng);
|
||||
}
|
||||
|
||||
var TILE_SIZE = 256;
|
||||
|
||||
MercatorProjection.prototype.TILE_SIZE = TILE_SIZE;
|
||||
|
||||
function bound(value, opt_min, opt_max) {
|
||||
if (opt_min != null) value = Math.max(value, opt_min);
|
||||
if (opt_max != null) value = Math.min(value, opt_max);
|
||||
return value;
|
||||
}
|
||||
|
||||
function degreesToRadians(deg) {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
|
||||
function radiansToDegrees(rad) {
|
||||
return rad / (Math.PI / 180);
|
||||
}
|
||||
|
||||
function MercatorProjection() {
|
||||
this.pixelOrigin_ = new Point(TILE_SIZE / 2,
|
||||
TILE_SIZE / 2);
|
||||
this.pixelsPerLonDegree_ = TILE_SIZE / 360;
|
||||
this.pixelsPerLonRadian_ = TILE_SIZE / (2 * Math.PI);
|
||||
}
|
||||
|
||||
MercatorProjection.prototype.fromLatLngToPixel = function(latLng, zoom) {
|
||||
var p = this.fromLatLngToPoint(latLng);
|
||||
return this.toPixelCoordinate(p, zoom);
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.fromLatLngToPoint = function(latLng,
|
||||
opt_point) {
|
||||
var me = this;
|
||||
var point = opt_point || new Point(0, 0);
|
||||
var origin = me.pixelOrigin_;
|
||||
|
||||
point.x = origin.x + latLng.lng * me.pixelsPerLonDegree_;
|
||||
|
||||
// NOTE(appleton): Truncating to 0.9999 effectively limits latitude to
|
||||
// 89.189. This is about a third of a tile past the edge of the world
|
||||
// tile.
|
||||
var siny = bound(Math.sin(degreesToRadians(latLng.lat)), -0.9999,
|
||||
0.9999);
|
||||
point.y = origin.y + 0.5 * Math.log((1 + siny) / (1 - siny)) *
|
||||
-me.pixelsPerLonRadian_;
|
||||
return point;
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.fromPointToLatLng = function(point) {
|
||||
var me = this;
|
||||
var origin = me.pixelOrigin_;
|
||||
var lng = (point.x - origin.x) / me.pixelsPerLonDegree_;
|
||||
var latRadians = (point.y - origin.y) / -me.pixelsPerLonRadian_;
|
||||
var lat = radiansToDegrees(2 * Math.atan(Math.exp(latRadians)) -
|
||||
Math.PI / 2);
|
||||
return new LatLng(lat, lng);
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.tileBBox = function(x, y, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var inc = TILE_SIZE/numTiles;
|
||||
var px = x*TILE_SIZE/numTiles;
|
||||
var py = y*TILE_SIZE/numTiles;
|
||||
return [
|
||||
this.fromPointToLatLng(new Point(px, py + inc)),
|
||||
this.fromPointToLatLng(new Point(px + inc, py))
|
||||
];
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.tilePoint = function(x, y, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var px = x*TILE_SIZE;
|
||||
var py = y*TILE_SIZE;
|
||||
return [px, py];
|
||||
}
|
||||
MercatorProjection.prototype.fromPixelToLatLng = function(pixel, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var p = new Point(
|
||||
pixel.x/numTiles,
|
||||
pixel.y/numTiles);
|
||||
return this.fromPointToLatLng(p);
|
||||
|
||||
|
||||
}
|
||||
|
||||
MercatorProjection.prototype.toPixelCoordinate = function(worldCoordinate, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
return new Point(
|
||||
worldCoordinate.x * numTiles,
|
||||
worldCoordinate.y * numTiles);
|
||||
}
|
||||
|
||||
MercatorProjection.prototype.latLngToTilePoint = function(latLng, x, y, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var projection = this;
|
||||
var worldCoordinate = projection.fromLatLngToPoint(latLng);
|
||||
var pixelCoordinate = new Point(
|
||||
worldCoordinate.x * numTiles,
|
||||
worldCoordinate.y * numTiles);
|
||||
var tp = this.tilePoint(x, y, zoom);
|
||||
return new Point(
|
||||
Math.floor(pixelCoordinate.x - tp[0]),
|
||||
Math.floor(pixelCoordinate.y - tp[1]));
|
||||
}
|
||||
|
||||
MercatorProjection.prototype.pixelToTile = function(pixelCoordinate) {
|
||||
return new Point(
|
||||
Math.floor(pixelCoordinate.x / TILE_SIZE),
|
||||
Math.floor(pixelCoordinate.y / TILE_SIZE));
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.pointToTile = function(point, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var pixelCoordinate = new Point(
|
||||
point.x * numTiles,
|
||||
point.y * numTiles);
|
||||
return this.pixelToTile(pixelCoordinate);
|
||||
};
|
||||
|
||||
MercatorProjection.prototype.latLngToTile = function(latLng, zoom) {
|
||||
var numTiles = 1 << zoom;
|
||||
var projection = this;
|
||||
var worldCoordinate = projection.fromLatLngToPoint(latLng);
|
||||
var pixelCoordinate = new Point(
|
||||
worldCoordinate.x * numTiles,
|
||||
worldCoordinate.y * numTiles);
|
||||
return new Point(
|
||||
Math.floor(pixelCoordinate.x / TILE_SIZE),
|
||||
Math.floor(pixelCoordinate.y / TILE_SIZE));
|
||||
}
|
59
spike/public/index.html
Normal file
59
spike/public/index.html
Normal file
@ -0,0 +1,59 @@
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
|
||||
<script src="http://localhost:8080/socket.io/socket.io.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="http://code.leafletjs.com/leaflet-0.3.1/leaflet.css" />
|
||||
<!--[if lte IE 8]>
|
||||
<link rel="stylesheet" href="http://code.leafletjs.com/leaflet-0.3.1/leaflet.ie.css" />
|
||||
<![endif]-->
|
||||
<script src="http://code.leafletjs.com/leaflet-0.3.1/leaflet.js"></script>
|
||||
<script>
|
||||
|
||||
$(document).ready(function () {
|
||||
var map = new L.Map('map');
|
||||
var cloudmade = new L.TileLayer('http://{s}.tile.cloudmade.com/BC9A493B41014CAABB98F0471D759707/997/256/{z}/{x}/{y}.png', {
|
||||
attribution: 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="http://cloudmade.com">CloudMade</a>',
|
||||
maxZoom: 18
|
||||
});
|
||||
var london = new L.LatLng(51.505, -0.09);
|
||||
map.setView(london, 3).addLayer(cloudmade);
|
||||
var geojson = new L.GeoJSON();
|
||||
map.addLayer(geojson);
|
||||
|
||||
var socket = io.connect('http://localhost:8080');
|
||||
socket.on('news', function (data) {
|
||||
console.log(data);
|
||||
socket.emit('my other event', { my:'data' });
|
||||
});
|
||||
|
||||
|
||||
$('#go').click(function () {
|
||||
var sql = $("#sql_input").val();
|
||||
socket.emit("sql_query", {sql:sql, id:'TUMADRE'});
|
||||
});
|
||||
|
||||
socket.on('sql_result', function(data){
|
||||
if (data.state == 1){
|
||||
//console.log(data.r['the_geom']);
|
||||
geojson.addGeoJSON(JSON.parse(data.r['the_geom']));
|
||||
}
|
||||
|
||||
//$('#sql_result').append(JSON.stringify(data));
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<input type="text" id="sql_input"></input>
|
||||
<button type="submit" id="go">go</button>
|
||||
<div id="map" style="height: 100%;width:100%;"></div>
|
||||
</body>
|
||||
</html>
|
266
spike/public/map.js
Normal file
266
spike/public/map.js
Normal file
@ -0,0 +1,266 @@
|
||||
|
||||
window.requestAnimFrame = (function(){
|
||||
return window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.oRequestAnimationFrame ||
|
||||
window.msRequestAnimationFrame ||
|
||||
function( callback ){
|
||||
window.setTimeout(callback, 1000 / 60);
|
||||
};
|
||||
})();
|
||||
|
||||
function Event() {}
|
||||
Event.prototype.on = function(evt, callback) {
|
||||
var cb = this.callbacks = this.callbacks || {};
|
||||
var l = cb[evt] || (cb[evt] = []);
|
||||
l.push(callback);
|
||||
};
|
||||
|
||||
Event.prototype.emit = function(evt) {
|
||||
var c = this.callbacks && this.callbacks[evt];
|
||||
for(var i = 0; c && i < c.length; ++i) {
|
||||
c[i].apply(this, Array.prototype.slice.call(arguments, 1));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
function MapModel(opts) {
|
||||
opts = opts || {};
|
||||
this.projection = new MercatorProjection();
|
||||
this.setCenter(opts.center || new LatLng(0,0));
|
||||
this.setZoom(opts.zoom || 1);
|
||||
}
|
||||
|
||||
MapModel.prototype = new Event();
|
||||
|
||||
MapModel.prototype.setCenter = function(center) {
|
||||
this.center = new LatLng(center.lat, center.lng);
|
||||
this.center_pixel = this.projection.fromLatLngToPixel(this.center, this.zoom).floor();
|
||||
this.emit('center_changed', this.center);
|
||||
};
|
||||
|
||||
MapModel.prototype.setZoom = function(zoom) {
|
||||
this.zoom = zoom;
|
||||
this.center_pixel = this.projection.fromLatLngToPixel(this.center, this.zoom).floor();
|
||||
this.emit('zoom_changed', this.center);
|
||||
};
|
||||
|
||||
MapModel.prototype.getCenterPixel = function() {
|
||||
var center_point = this.projection.fromLatLngToPixel(this.center, this.zoom);
|
||||
return center_point;
|
||||
}
|
||||
|
||||
MapModel.prototype.getTopLeft = function(width, height) {
|
||||
var center_point = this.projection.fromLatLngToPixel(this.center, this.zoom);
|
||||
var widthHalf = width / 2;
|
||||
var heightHalf = height / 2;
|
||||
center_point.x -= widthHalf;
|
||||
center_point.y -= heightHalf;
|
||||
return center_point;
|
||||
}
|
||||
MapModel.prototype.getBBox = function(width, height) {
|
||||
var center_point = this.projection.fromLatLngToPixel(this.center, this.zoom);
|
||||
var widthHalf = width / 2;
|
||||
var heightHalf = height / 2;
|
||||
center_point.x -= widthHalf;
|
||||
center_point.y += heightHalf;
|
||||
var bottomleft = this.projection.fromPixelToLatLng(center_point, this.zoom);
|
||||
center_point.x += width;
|
||||
center_point.y -= height;
|
||||
var topRight = this.projection.fromPixelToLatLng(center_point, this.zoom);
|
||||
return [bottomleft, topRight]
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* return a list of tiles inside the spcified zone
|
||||
* the center will be placed on the center of that zone
|
||||
*/
|
||||
MapModel.prototype.visibleTiles = function(width, height) {
|
||||
var self = this;
|
||||
var widthHalf = width / 2;
|
||||
var heightHalf = height / 2;
|
||||
var center_point = self.projection.fromLatLngToPixel(self.center, self.zoom);
|
||||
center_point.x -= widthHalf;
|
||||
center_point.y -= heightHalf;
|
||||
var tile = this.projection.pixelToTile(center_point, self.zoom);
|
||||
var offset_x = center_point.x%this.projection.TILE_SIZE;
|
||||
var offset_y = center_point.y%this.projection.TILE_SIZE;
|
||||
|
||||
var num_tiles_x = Math.ceil((width + offset_x)/this.projection.TILE_SIZE);
|
||||
var num_tiles_y = Math.ceil((height + offset_y)/this.projection.TILE_SIZE);
|
||||
|
||||
var tiles = [];
|
||||
for(var i = 0; i < num_tiles_x; ++i) {
|
||||
for(var j = 0; j < num_tiles_y; ++j) {
|
||||
var tile_x = tile.x + i;
|
||||
var tile_y = tile.y + j;
|
||||
tiles.push({
|
||||
x: tile_x * this.projection.TILE_SIZE,
|
||||
y: tile_y * this.projection.TILE_SIZE,
|
||||
zoom: self.zoom,
|
||||
i: tile_x,
|
||||
j: tile_y
|
||||
});
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
|
||||
}
|
||||
|
||||
function dragger(el) {
|
||||
|
||||
var self = this;
|
||||
var dragging = false;
|
||||
var x, y;
|
||||
|
||||
el.ontouchstart = el.onmousedown = function(e) {
|
||||
dragging = true;
|
||||
if (e.touches) {
|
||||
var p = e.touches[0];
|
||||
x = p.pageX;
|
||||
y = p.pageY;
|
||||
} else {
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
}
|
||||
self.emit('startdrag', x, y);
|
||||
};
|
||||
|
||||
el.ontouchmove = el.onmousemove = function(e) {
|
||||
var xx, yy;
|
||||
if(!dragging) return;
|
||||
if (e.touches) {
|
||||
var p = e.touches[0];
|
||||
xx = p.pageX;
|
||||
yy = p.pageY;
|
||||
} else {
|
||||
xx = e.clientX;
|
||||
yy = e.clientY;
|
||||
}
|
||||
self.emit('move', xx - x, yy - y);
|
||||
return false;
|
||||
};
|
||||
|
||||
el.ontouchend = el.onmouseup = function(e) {
|
||||
dragging = false;
|
||||
self.emit('enddrag', x, y);
|
||||
};
|
||||
}
|
||||
|
||||
dragger.prototype = new Event();
|
||||
|
||||
function CanvasRenderer(el, map) {
|
||||
var self = this;
|
||||
this.el = el;
|
||||
this.tiles = {};
|
||||
this.width = el.offsetWidth >> 0;
|
||||
this.height = el.offsetHeight >> 0;
|
||||
var widthHalf = (this.width / 2) >> 0;
|
||||
var heightHalf = (this.height / 2) >> 0;
|
||||
|
||||
var canvas = this.canvas = document.createElement('canvas');
|
||||
canvas.style.padding = '0';
|
||||
canvas.style.margin= '0';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.width = this.width;
|
||||
canvas.height = this.height;
|
||||
|
||||
var context = canvas.getContext( '2d' );
|
||||
context.translate( widthHalf, heightHalf );
|
||||
this.context = context;
|
||||
|
||||
var div = document.createElement('div');
|
||||
div.style.width = this.width + "px";
|
||||
div.style.height= this.height + "px";
|
||||
div.style.position = 'relative';
|
||||
div.appendChild(canvas);
|
||||
el.appendChild(div);
|
||||
|
||||
this.center_init = null;
|
||||
this.target_center = new LatLng();
|
||||
this.drag = new dragger(div);
|
||||
this.drag.on('startdrag', function() {
|
||||
self.center_init = map.center.clone();
|
||||
});
|
||||
this.drag.on('enddrag', function() {
|
||||
map.emit('end_move');
|
||||
});
|
||||
|
||||
function go_to_target() {
|
||||
var c = map.center;
|
||||
var t = self.target_center;
|
||||
var dlat = t.lat - c.lat;
|
||||
var dlon = t.lng - c.lng;
|
||||
t.lat += dlat*0.0001;
|
||||
t.lng += dlon*0.0001;
|
||||
map.setCenter(t);
|
||||
if(Math.abs(dlat) + Math.abs(dlon) > 0.001) {
|
||||
requestAnimFrame(go_to_target);
|
||||
} else {
|
||||
//map.emit('end_move');
|
||||
}
|
||||
}
|
||||
|
||||
this.drag.on('move', function(dx, dy) {
|
||||
var t = 1 << map.zoom;
|
||||
var s = 1/t;
|
||||
s = s/map.projection.pixelsPerLonDegree_;
|
||||
self.target_center.lat = self.center_init.lat + dy*s;
|
||||
self.target_center.lng = self.center_init.lng - dx*s;
|
||||
requestAnimFrame(go_to_target);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
CanvasRenderer.prototype.renderTile = function(tile, at) {
|
||||
var self = this;
|
||||
var key = at.x + '_' + at.y
|
||||
if(a=self.tiles[key]) {
|
||||
self.context.drawImage(a, at.x, at.y);
|
||||
return;
|
||||
}
|
||||
|
||||
//var layer = 'http://a.tile.cloudmade.com/BC9A493B41014CAABB98F0471D759707/997/256/{{z}}/{{x}}/{{y}}.png';
|
||||
var layer = 'http://b.tiles.mapbox.com/v3/mapbox.mapbox-light/{{z}}/{{x}}/{{y}}.png64';
|
||||
var url = layer.replace('{{z}}', tile.zoom).replace('{{x}}', tile.i).replace('{{y}}', tile.j);
|
||||
var img = new Image();
|
||||
img.src = url;
|
||||
img.onload = function() {
|
||||
self.context.drawImage(img, at.x, at.y);
|
||||
self.tiles[key] = img;
|
||||
};
|
||||
}
|
||||
|
||||
CanvasRenderer.prototype.renderTiles = function(tiles, center) {
|
||||
for(var i = 0; i < tiles.length; ++i) {
|
||||
var tile = tiles[i];
|
||||
var p = new Point(tile.x, tile.y);
|
||||
p.x -= center.x;
|
||||
p.y -= center.y;
|
||||
this.renderTile(tile, p);
|
||||
}
|
||||
}
|
||||
|
||||
function Map(el, opts) {
|
||||
opts = opts || {};
|
||||
var self = this;
|
||||
this.model = new MapModel({
|
||||
center: opts.center || new LatLng(41.69, -4.83),
|
||||
zoom: opts.zoom || 1
|
||||
});
|
||||
this.view = new CanvasRenderer(el, this.model);
|
||||
/*function render() {
|
||||
var tiles = self.model.visibleTiles(self.view.width, self.view.height);
|
||||
self.view.renderTiles(tiles, this.center_pixel);
|
||||
}
|
||||
this.model.on('center_changed', render);
|
||||
this.model.on('zoom_changed', render);
|
||||
this.model.emit('center_changed');
|
||||
*/
|
||||
}
|
||||
|
1
spike/public/run_server.sh
Executable file
1
spike/public/run_server.sh
Executable file
@ -0,0 +1 @@
|
||||
python -m SimpleHTTPServer 8000
|
111
spike/public/test.html
Normal file
111
spike/public/test.html
Normal file
@ -0,0 +1,111 @@
|
||||
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no">
|
||||
<style>
|
||||
|
||||
html, body, #map {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.button {
|
||||
position: absolute;
|
||||
background-color: #888;
|
||||
font: bold 16px Helvetica;
|
||||
padding: 5px 10px;
|
||||
width: 10px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
#plus {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
}
|
||||
#minus {
|
||||
top: 40px;
|
||||
left: 73px;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #BBB;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<a id="plus" href="#" class="button">+</a>
|
||||
<a id="minus" href="#" class="button">-</a>
|
||||
</body>
|
||||
|
||||
<script src="gmaps_mercator.js"></script>
|
||||
<script src="map.js"></script>
|
||||
<script src="http://localhost:8080/socket.io/socket.io.js"></script>
|
||||
<script>
|
||||
map = new Map(document.getElementById('map'), {
|
||||
center: new LatLng(41.69, -4.83),
|
||||
zoom: 4
|
||||
});
|
||||
document.getElementById('plus').onclick = function() {
|
||||
map.model.setZoom(map.model.zoom + 1);
|
||||
return false;
|
||||
}
|
||||
document.getElementById('minus').onclick = function() {
|
||||
map.model.setZoom(map.model.zoom - 1);
|
||||
return false;
|
||||
}
|
||||
function render() {
|
||||
//var tiles = self.model.visibleTiles(self.view.width, self.view.height);
|
||||
//self.view.renderTiles(tiles, this.center_pixel);
|
||||
}
|
||||
var socket = io.connect('http://localhost:8080');
|
||||
|
||||
function get_sql() {
|
||||
var bbox = map.model.getBBox(map.view.width, map.view.height);
|
||||
var sql = "select st_asgeojson(the_geom,5) as the_geom_geojson from places WHERE the_geom && ST_SetSRID(ST_MakeBox2D(";
|
||||
sql += "ST_Point(" + bbox[0].lng + "," + bbox[0].lat +"),";
|
||||
sql += "ST_Point(" + bbox[1].lng + "," + bbox[1].lat +")), 4326)";
|
||||
|
||||
return sql ;
|
||||
}
|
||||
|
||||
map.model.on('end_move', jajaja=function() {
|
||||
|
||||
var s = get_sql();
|
||||
//console.log(s);
|
||||
//setTimeout(function() {
|
||||
socket.emit("sql_query", {sql: s, id:'LOVELY'});
|
||||
//}, 1000);
|
||||
var tiles = map.model.visibleTiles(map.view.width, map.view.height);
|
||||
map.view.renderTiles(tiles, this.center_pixel);
|
||||
});
|
||||
|
||||
function add_point(p) {
|
||||
var p = new LatLng(p[1], p[0]);
|
||||
var px = map.model.projection.fromLatLngToPixel(p, map.model.zoom);
|
||||
var centerpx= map.model.getCenterPixel(map.view.width, map.view.height);
|
||||
map.view.context.fillStyle = '#000';
|
||||
map.view.context.fillRect(px.x - centerpx.x , px.y - centerpx.y, 2, 2);
|
||||
|
||||
}
|
||||
socket.on('sql_result', function(data){
|
||||
if (data.state == 1){
|
||||
geo = JSON.parse(data.r['the_geom_geojson']);
|
||||
add_point(geo.coordinates);
|
||||
}
|
||||
});
|
||||
|
||||
map.model.on('zoom_changed', jajaja);
|
||||
map.model.emit('center_changed');
|
||||
|
||||
</script>
|
||||
</html>
|
57
spike/public/test/map.test.js
Normal file
57
spike/public/test/map.test.js
Normal file
@ -0,0 +1,57 @@
|
||||
describe("Event", function() {
|
||||
|
||||
before_each(function() {
|
||||
this.evt = new Event();
|
||||
});
|
||||
|
||||
it("call event binded", function() {
|
||||
var c = 0;
|
||||
function callback() {
|
||||
c++;
|
||||
}
|
||||
this.evt.on('test', callback);
|
||||
this.evt.emit('test');
|
||||
assert(c == 1);
|
||||
});
|
||||
|
||||
it("should works when call non existing event", function() {
|
||||
this.evt.emit('test_no_exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapModel", function() {
|
||||
|
||||
before_each(function() {
|
||||
this.map_model = new MapModel(new LatLng(0, 0));
|
||||
});
|
||||
|
||||
it("center_changed should be called", function() {
|
||||
var c = 0;
|
||||
this.map_model.on('center_changed', function() {
|
||||
++c;
|
||||
});
|
||||
this.map_model.setCenter(new LatLng(0, 0));
|
||||
assert(c == 1);
|
||||
});
|
||||
|
||||
it("zoom_changed should be called", function() {
|
||||
var c = 0;
|
||||
this.map_model.on('zoom_changed', function() {
|
||||
++c;
|
||||
});
|
||||
this.map_model.setZoom(2);
|
||||
assert(c == 1);
|
||||
});
|
||||
|
||||
it("visibleTiles", function() {
|
||||
var ts = this.map_model.projection.TILE_SIZE;
|
||||
this.map_model.setZoom(10);
|
||||
var tiles = this.map_model.visibleTiles(ts, ts);
|
||||
assert(tiles.length == 4);
|
||||
this.map_model.setCenter(new LatLng(0.3, 1.2));
|
||||
tiles = this.map_model.visibleTiles(ts, ts);
|
||||
assert(tiles.length == 4);
|
||||
});
|
||||
|
||||
|
||||
});
|
62
spike/public/test/suite.html
Normal file
62
spike/public/test/suite.html
Normal file
@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
<!doctype html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<title>quick'n'dirty js testing framework</title>
|
||||
<meta charset=utf-8>
|
||||
<style type="text/css" media="screen">
|
||||
h1{ font: 1.5em Helvetica; }
|
||||
p { font: .9em courier; padding: .5em 1em; margin: 0;}
|
||||
.result { margin-top: 1px; }
|
||||
.pass { color: #4F8A10; background: #DFF2BF }
|
||||
.fail { color: #D8000C; background: #FFBABA }
|
||||
.error{ color: white; background: red; font-weight: bold; }
|
||||
.describe { border-bottom: 1px solid #ccc; padding: 20px; }
|
||||
</style>
|
||||
<script>
|
||||
var setup_fn = null;
|
||||
function describe(desc, fn) {
|
||||
document.write('<div class="describe">');
|
||||
document.write('<h1>' + desc +'</h1>');
|
||||
fn();
|
||||
document.write('</div>');
|
||||
setup_fn = null;
|
||||
}
|
||||
|
||||
function before_each(be) {
|
||||
setup_fn = be;
|
||||
}
|
||||
|
||||
var result = true;
|
||||
function assert(cond, msg) {
|
||||
if(result) {
|
||||
if(!cond) {
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function it(desc, fn) {
|
||||
var output = function(klass, msg){
|
||||
document.write('<p class="result ' + klass + '">' + msg + '</p>')
|
||||
}
|
||||
try{
|
||||
if (setup_fn != null)
|
||||
setup_fn();
|
||||
result = true;
|
||||
fn()
|
||||
} catch(err){ result = err }
|
||||
if (typeof result === 'boolean'){
|
||||
result ? output('pass', desc) : output('fail', desc)
|
||||
}else{
|
||||
output('error', ['ERROR:',result.message,'when testing',desc].join(' '))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="../gmaps_mercator.js"> </script>
|
||||
<script src="../map.js"> </script>
|
||||
<script src="map.test.js"> </script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
32
test/acceptance/app.auth.test.js
Normal file
32
test/acceptance/app.auth.test.js
Normal file
@ -0,0 +1,32 @@
|
||||
require('../helper');
|
||||
|
||||
var app = require(global.settings.app_root + '/app/controllers/app')
|
||||
, assert = require('assert')
|
||||
, tests = module.exports = {}
|
||||
, querystring = require('querystring');
|
||||
|
||||
tests['valid api key should allow insert in protected tables'] = function(){
|
||||
assert.response(app, {
|
||||
// view prepare_db.sh to see where to set api_key
|
||||
url: "/api/v1/sql?api_key=1234&q=INSERT%20INTO%20private_table%20(name)%20VALUES%20('test')&database=cartodb_dev_user_1_db",
|
||||
|
||||
headers: {host: 'vizzuality.cartodb.com' },
|
||||
method: 'GET'
|
||||
},{
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
tests['invalid api key should NOT allow insert in protected tables'] = function(){
|
||||
assert.response(app, {
|
||||
// view prepare_db.sh to see where to set api_key
|
||||
url: "/api/v1/sql?api_key=RAMBO&q=INSERT%20INTO%20private_table%20(name)%20VALUES%20('test')&database=cartodb_dev_user_1_db",
|
||||
|
||||
headers: {host: 'vizzuality.cartodb.com' },
|
||||
method: 'GET'
|
||||
},{
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -18,8 +18,10 @@ var app = require(global.settings.app_root + '/app/controllers/app')
|
||||
, tests = module.exports = {}
|
||||
, querystring = require('querystring');
|
||||
|
||||
var real_oauth_header = 'OAuth realm="http://vizzuality.testhost.lan/",oauth_consumer_key="fZeNGv5iYayvItgDYHUbot1Ukb5rVyX6QAg8GaY2",oauth_token="l0lPbtP68ao8NfStCiA3V3neqfM03JKhToxhUQTR",oauth_signature_method="HMAC-SHA1", oauth_signature="o4hx4hWP6KtLyFwggnYB4yPK8xI%3D",oauth_timestamp="1313581372",oauth_nonce="W0zUmvyC4eVL8cBd4YwlH1nnPTbxW0QBYcWkXTwe4",oauth_version="1.0"';
|
||||
// allow lots of emitters to be set to silence warning
|
||||
app.setMaxListeners(0);
|
||||
|
||||
var real_oauth_header = 'OAuth realm="http://vizzuality.testhost.lan/",oauth_consumer_key="fZeNGv5iYayvItgDYHUbot1Ukb5rVyX6QAg8GaY2",oauth_token="l0lPbtP68ao8NfStCiA3V3neqfM03JKhToxhUQTR",oauth_signature_method="HMAC-SHA1", oauth_signature="o4hx4hWP6KtLyFwggnYB4yPK8xI%3D",oauth_timestamp="1313581372",oauth_nonce="W0zUmvyC4eVL8cBd4YwlH1nnPTbxW0QBYcWkXTwe4",oauth_version="1.0"';
|
||||
|
||||
|
||||
tests['GET /api/v1/sql'] = function(){
|
||||
@ -160,4 +162,4 @@ function checkDecimals(x, dec_sep){
|
||||
return tmp.length-tmp.indexOf(dec_sep)-1;
|
||||
else
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@
|
||||
echo "preparing redis..."
|
||||
echo "HSET rails:users:vizzuality id 1" | redis-cli -n 5
|
||||
echo "HSET rails:users:vizzuality database_name cartodb_test_user_1_db" | redis-cli -n 5
|
||||
echo "SADD rails:users:vizzuality:map_key 1234" | redis-cli -n 5
|
||||
|
||||
echo "preparing postgres..."
|
||||
dropdb -Upostgres -hlocalhost cartodb_test_user_1_db
|
||||
|
@ -62,8 +62,51 @@ ALTER TABLE ONLY untitle_table_4 ADD CONSTRAINT test_table_pkey PRIMARY KEY (car
|
||||
CREATE INDEX test_table_the_geom_idx ON untitle_table_4 USING gist (the_geom);
|
||||
CREATE INDEX test_table_the_geom_webmercator_idx ON untitle_table_4 USING gist (the_geom_webmercator);
|
||||
|
||||
CREATE TABLE private_table (
|
||||
updated_at timestamp without time zone DEFAULT now(),
|
||||
created_at timestamp without time zone DEFAULT now(),
|
||||
cartodb_id integer NOT NULL,
|
||||
name character varying,
|
||||
address character varying,
|
||||
the_geom geometry,
|
||||
the_geom_webmercator geometry,
|
||||
CONSTRAINT enforce_dims_the_geom CHECK ((st_ndims(the_geom) = 2)),
|
||||
CONSTRAINT enforce_dims_the_geom_webmercator CHECK ((st_ndims(the_geom_webmercator) = 2)),
|
||||
CONSTRAINT enforce_geotype_the_geom CHECK (((geometrytype(the_geom) = 'POINT'::text) OR (the_geom IS NULL))),
|
||||
CONSTRAINT enforce_geotype_the_geom_webmercator CHECK (((geometrytype(the_geom_webmercator) = 'POINT'::text) OR (the_geom_webmercator IS NULL))),
|
||||
CONSTRAINT enforce_srid_the_geom CHECK ((st_srid(the_geom) = 4326)),
|
||||
CONSTRAINT enforce_srid_the_geom_webmercator CHECK ((st_srid(the_geom_webmercator) = 3857))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE test_table_cartodb_id_seq_p
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE test_table_cartodb_id_seq_p OWNED BY private_table.cartodb_id;
|
||||
|
||||
SELECT pg_catalog.setval('test_table_cartodb_id_seq_p', 60, true);
|
||||
|
||||
ALTER TABLE private_table ALTER COLUMN cartodb_id SET DEFAULT nextval('test_table_cartodb_id_seq_p'::regclass);
|
||||
|
||||
INSERT INTO private_table VALUES ('2011-09-21 14:02:21.358706', '2011-09-21 14:02:21.314252', 1, 'Hawai', 'Calle de Pérez Galdós 9, Madrid, Spain', '0101000020E6100000A6B73F170D990DC064E8D84125364440', '0101000020110F000076491621312319C122D4663F1DCC5241');
|
||||
INSERT INTO private_table VALUES ('2011-09-21 14:02:21.358706', '2011-09-21 14:02:21.319101', 2, 'El Estocolmo', 'Calle de la Palma 72, Madrid, Spain', '0101000020E6100000C90567F0F7AB0DC0AB07CC43A6364440', '0101000020110F0000C4356B29423319C15DD1092DADCC5241');
|
||||
INSERT INTO private_table VALUES ('2011-09-21 14:02:21.358706', '2011-09-21 14:02:21.324', 3, 'El Rey del Tallarín', 'Plaza Conde de Toreno 2, Madrid, Spain', '0101000020E610000021C8410933AD0DC0CB0EF10F5B364440', '0101000020110F000053E71AC64D3419C10F664E4659CC5241');
|
||||
INSERT INTO private_table VALUES ('2011-09-21 14:02:21.358706', '2011-09-21 14:02:21.329509', 4, 'El Lacón', 'Manuel Fernández y González 8, Madrid, Spain', '0101000020E6100000BC5983F755990DC07D923B6C22354440', '0101000020110F00005DACDB056F2319C1EC41A980FCCA5241');
|
||||
INSERT INTO private_table VALUES ('2011-09-21 14:02:21.358706', '2011-09-21 14:02:21.334931', 5, 'El Pico', 'Calle Divino Pastor 12, Madrid, Spain', '0101000020E61000003B6D8D08C6A10DC0371B2B31CF364440', '0101000020110F00005F716E91992A19C17DAAA4D6DACC5241');
|
||||
|
||||
ALTER TABLE ONLY private_table ADD CONSTRAINT test_table_pkey_p PRIMARY KEY (cartodb_id);
|
||||
|
||||
CREATE INDEX test_table_the_geom_idx_p ON private_table USING gist (the_geom);
|
||||
CREATE INDEX test_table_the_geom_webmercator_idx_p ON private_table USING gist (the_geom_webmercator);
|
||||
|
||||
|
||||
|
||||
CREATE USER publicuser WITH PASSWORD '';
|
||||
CREATE USER test_cartodb_user_1 WITH PASSWORD '';
|
||||
|
||||
GRANT SELECT ON TABLE untitle_table_4 TO publicuser;
|
||||
GRANT ALL ON TABLE private_table TO test_cartodb_user_1;
|
||||
GRANT ALL ON SEQUENCE test_table_cartodb_id_seq_p TO test_cartodb_user_1
|
||||
|
Loading…
Reference in New Issue
Block a user