1var CKAN = {}; 2 3var isNodeModule = (typeof module !== 'undefined' && module != null && typeof require !== 'undefined'); 4 5if (isNodeModule) { 6 var _ = require('underscore'); 7 module.exports = CKAN; 8} 9 10(function(my) { 11 my.Client = function(endpoint, apiKey) { 12 this.endpoint = _getEndpoint(endpoint); 13 this.apiKey = apiKey; 14 }; 15 16 // Security function to validate action names 17 var _validateActionName = function(name) { 18 // Input validation 19 if (!name || typeof name !== 'string') { 20 return null; 21 } 22 23 // Whitelist of allowed CKAN API actions 24 var allowedActions = [ 25 'datastore_search', 'datastore_search_sql', 'package_search', 26 'resource_search', 'package_show', 'resource_show', 27 'organization_list', 'group_list', 'tag_list' 28 ]; 29 30 // Normalize the action name 31 var actionName = name.toLowerCase().trim(); 32 33 // Handle dataset_ to package_ mapping 34 if (actionName.indexOf('dataset_') === 0) { 35 actionName = actionName.replace('dataset_', 'package_'); 36 } 37 38 // Only allow whitelisted actions 39 if (allowedActions.indexOf(actionName) === -1) { 40 return null; 41 } 42 43 // Additional security: ensure no path traversal characters 44 if (actionName.includes('..') || actionName.includes('/') || actionName.includes('\\')) { 45 return null; 46 } 47 48 return actionName; 49 }; 50 51 my.Client.prototype.action = function(name, data, cb) { 52 // Validate and sanitize the action name 53 var validatedName = _validateActionName(name); 54 if (!validatedName) { 55 if (cb) cb(new Error('Invalid or unauthorized action name: ' + name)); 56 return; 57 } 58 59 // Construct URL safely 60 var baseUrl = this.endpoint + '/3/action/'; 61 var url = new URL(validatedName, baseUrl).toString(); 62 63 var options = { 64 url: url, 65 data: data, 66 type: 'POST' 67 }; 68 return this._ajax(options, cb); 69 }; 70 71 // make an AJAX request 72 my.Client.prototype._ajax = function(options, cb) { 73 options.headers = options.headers || {}; 74 if (this.apiKey) { 75 options.headers['X-CKAN-API-KEY'] = this.apiKey; 76 } 77 var meth = isNodeModule ? _nodeRequest : _browserRequest; 78 return meth(options, cb); 79 } 80 81 // Like search but supports ReclineJS style query structure 82 // 83 // Primarily for use by Recline backend below 84 my.Client.prototype.datastoreQuery = function(queryObj, cb) { 85 var actualQuery = my._normalizeQuery(queryObj); 86 this.action('datastore_search', actualQuery, function(err, results) { 87 if (err) { 88 cb(err); 89 return; 90 } 91 92 // map ckan types to our usual types ... 93 var fields = _.map(results.result.fields, function(field) { 94 field.type = field.type in my.ckan2JsonTableSchemaTypes ? my.ckan2JsonTableSchemaTypes[field.type] : field.type; 95 return field; 96 }); 97 var out = { 98 total: results.result.total, 99 fields: fields, 100 hits: results.result.records 101 }; 102 cb(null, out); 103 }); 104 }; 105 106 my.Client.prototype.datastoreSqlQuery = function(sql, cb) { 107 this.action('datastore_search_sql', {sql: sql}, function(err, results) { 108 if (err) { 109 var parsed = JSON.parse(err.message); 110 var errOut = { 111 original: err, 112 code: err.code, 113 message: parsed.error.info.orig[0] 114 }; 115 cb(errOut); 116 return; 117 } 118 119 // map ckan types to our usual types ... 120 var fields = _.map(results.result.fields, function(field) { 121 field.type = field.type in my.ckan2JsonTableSchemaTypes ? my.ckan2JsonTableSchemaTypes[field.type] : field.type; 122 return field; 123 }); 124 var out = { 125 total: results.result.length, 126 fields: fields, 127 hits: results.result.records 128 }; 129 cb(null, out); 130 }); 131 }; 132 133 my.ckan2JsonTableSchemaTypes = { 134 'text': 'string', 135 'int': 'integer', 136 'int4': 'integer', 137 'int8': 'integer', 138 'float8': 'float', 139 'timestamp': 'datetime', 140 'bool': 'boolean', 141 }; 142 143 // 144 my.jsonTableSchema2CkanTypes = { 145 'string': 'text', 146 'number': 'float', 147 'integer': 'int', 148 'datetime': 'timestamp', 149 'boolean': 'bool', 150 'binary': 'bytea', 151 'object': 'json', 152 'array': 'text[]', 153 'any': 'text' 154 }; 155 156 // list all the resources with an entry in the DataStore 157 my.Client.prototype.datastoreResources = function(cb) { 158 var data = { 159 resource_id: '_table_metadata' 160 }; 161 return this.action('datastore_search', data, cb); 162 }; 163 164 // Utilities 165 // ========= 166
167 var _getEndpoint = function(endpoint) { 168 endpoint = endpoint || '/'; 169 170 // Security validation for endpoint 171 if (typeof endpoint !== 'string') { 172 endpoint = '/'; 173 } 174 175 // Prevent malicious URLs 176 if (endpoint.includes('<') || endpoint.includes('>') || endpoint.includes('"')) { 177 endpoint = '/'; 178 } 179 180 // Only allow safe protocols for absolute URLs 181 if (endpoint.match(/^https?:\/\//)) { 182 try { 183 var parsedUrl = new URL(endpoint); 184 if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { 185 endpoint = '/'; 186 } 187 } catch (e) { 188 endpoint = '/'; 189 } 190 } 191 192 // strip trailing / 193 endpoint = endpoint.replace(/\/$/, ''); 194 if (!endpoint.match(/\/api$/)) { 195 endpoint += '/api'; 196 } 197 return endpoint; 198 }; 199 200 var _nodeRequest = function(options, cb) { 201 var conf = { 202 headers: { 203 'Content-Type': 'application/json', 204 ...options.headers 205 }, 206 method: options.type || 'GET', 207 body: JSON.stringify(options.data) 208 }; 209 // we could just call request but that's a PITA to mock plus request.get = request (if you look at the source code) 210 fetch(options.url, conf).then(function(res) { 211 res.json().then(function(data) { 212 if (res.ok) { 213 cb(null, data); 214 } else { 215 cb(data, null); 216 } 217 }); 218 }).catch(function (err) { 219 if (!err && res && !(res.statusCode === 200 || res.statusCode === 302)) { 220 err = 'CKANJS API Error. HTTP code ' + res.statusCode + '. Message: ' + JSON.stringify(body, null, 2); 221 } 222 }); 223 224 }; 225 226 var _browserRequest = function(options, cb) { 227 var self = this; 228 options.data = encodeURIComponent(JSON.stringify(options.data)); 229 options.success = function(data) { 230 cb(null, data); 231 } 232 options.error = function(obj, obj2, obj3) { 233 var err = { 234 code: obj.status, 235 message: obj.responseText 236 } 237 cb(err); 238 } 239 if (options.headers) { 240 options.beforeSend = function(req) { 241 for (key in options.headers) { 242 req.setRequestHeader(key, options.headers[key]); 243 } 244 }; 245 } 246 return jQuery.ajax(options); 247 }; 248 249 // only put in the module namespace so we can access for tests! 250 my._normalizeQuery = function(queryObj) { 251 var actualQuery = { 252 resource_id: queryObj.resource_id, 253 q: queryObj.q, 254 filters: {}, 255 limit: queryObj.size || 10, 256 offset: queryObj.from || 0 257 }; 258 259 if (queryObj.sort && queryObj.sort.length > 0) { 260 var _tmp = _.map(queryObj.sort, function(sortObj) { 261 return sortObj.field + ' ' + (sortObj.order || ''); 262 }); 263 actualQuery.sort = _tmp.join(','); 264 } 265 266 if (queryObj.filters && queryObj.filters.length > 0) { 267 _.each(queryObj.filters, function(filter) { 268 if (filter.type === "term") { 269 actualQuery.filters[filter.field] = filter.term; 270 } 271 }); 272 } 273 return actualQuery; 274 }; 275 276 // Parse a normal CKAN resource URL and return API endpoint etc 277 // 278 // Normal URL is something like http://demo.ckan.org/dataset/some-dataset/resource/eb23e809-ccbb-4ad1-820a-19586fc4bebd 279 // 280 // :return: { resource_id: ..., endpoint: ... } 281 my.parseCkanResourceUrl = function(url) { 282 parts = url.split('/'); 283 var len = parts.length; 284 return { 285 resource_id: parts[len-1], 286 endpoint: parts.slice(0,[len-4]).join('/') + '/api' 287 }; 288 }; 289}(CKAN));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.