AJ ONeal
4 years ago
23 changed files with 1635 additions and 95 deletions
@ -0,0 +1 @@ |
|||||
|
package-lock.json |
@ -0,0 +1,16 @@ |
|||||
|
{ "node": true |
||||
|
, "browser": true |
||||
|
, "globals": { "Promise": true } |
||||
|
, "esversion": 8 |
||||
|
|
||||
|
, "indent": 2 |
||||
|
, "onevar": true |
||||
|
, "laxbreak": true |
||||
|
, "curly": true |
||||
|
, "nonbsp": true |
||||
|
|
||||
|
, "eqeqeq": true |
||||
|
, "immed": true |
||||
|
, "undef": true |
||||
|
, "unused": true |
||||
|
} |
@ -0,0 +1 @@ |
|||||
|
dist/ |
@ -0,0 +1,8 @@ |
|||||
|
{ |
||||
|
"bracketSpacing": true, |
||||
|
"printWidth": 80, |
||||
|
"singleQuote": true, |
||||
|
"tabWidth": 4, |
||||
|
"trailingComma": "none", |
||||
|
"useTabs": true |
||||
|
} |
@ -0,0 +1,79 @@ |
|||||
|
'use strict'; |
||||
|
/*global crypto*/ |
||||
|
|
||||
|
var Hashcash = module.exports; |
||||
|
|
||||
|
var textEncoder = new TextEncoder(); |
||||
|
Hashcash.solve = async function solveHc(hc) { |
||||
|
var solution = 0; |
||||
|
var parts = hc.split(':').slice(0, 6); |
||||
|
if (parts.length < 6) { |
||||
|
throw new Error('invalid Hashcash-Challenge: ' + hc); |
||||
|
} |
||||
|
|
||||
|
var bits = parseInt(parts[1], 10) || -1; |
||||
|
if (bits > 10 || bits < 0) { |
||||
|
throw new Error('bad bit values'); |
||||
|
} |
||||
|
console.log('bits:', bits); |
||||
|
hc = parts.join(':') + ':'; |
||||
|
async function next() { |
||||
|
var answer = hc + int52ToBase64(solution); |
||||
|
var u8 = textEncoder.encode(answer); |
||||
|
// REALLY SLOW due to async tasks and C++ context switch
|
||||
|
var hash = await crypto.subtle.digest('SHA-256', u8); |
||||
|
hash = new Uint8Array(hash); |
||||
|
if (checkHc(hash, bits)) { |
||||
|
return answer; |
||||
|
} |
||||
|
solution += 1; |
||||
|
return next(); |
||||
|
} |
||||
|
|
||||
|
return next(); |
||||
|
}; |
||||
|
|
||||
|
function int52ToBase64(n) { |
||||
|
var hex = n.toString(16); |
||||
|
if (hex.length % 2) { |
||||
|
hex = '0' + hex; |
||||
|
} |
||||
|
|
||||
|
var bin = []; |
||||
|
var i = 0; |
||||
|
var d; |
||||
|
var b; |
||||
|
while (i < hex.length) { |
||||
|
d = parseInt(hex.slice(i, i + 2), 16); |
||||
|
b = String.fromCharCode(d); |
||||
|
bin.push(b); |
||||
|
i += 2; |
||||
|
} |
||||
|
|
||||
|
return btoa(bin.join('')).replace(/=/g, ''); |
||||
|
} |
||||
|
|
||||
|
function checkHc(hash, bits) { |
||||
|
var n = Math.floor(bits / 8); |
||||
|
var m = bits % 8; |
||||
|
var i; |
||||
|
if (m > 0) { |
||||
|
n += 1; |
||||
|
} |
||||
|
|
||||
|
for (i = 0; i < n && i < hash.length; i += 1) { |
||||
|
if (bits > 8) { |
||||
|
bits -= 8; |
||||
|
if (0 !== hash[i]) { |
||||
|
return false; |
||||
|
} |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (0 !== hash[i] >> (8 - bits)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
@ -0,0 +1,199 @@ |
|||||
|
'use strict'; |
||||
|
|
||||
|
var request = require('./request.js'); |
||||
|
var PocketId = require('./pocketid.js'); |
||||
|
var state = {}; |
||||
|
var auths = clearAuths(); |
||||
|
|
||||
|
function $$(sel, el) { |
||||
|
if (el) { |
||||
|
return el.querySelectorAll(sel) || []; |
||||
|
} |
||||
|
return document.body.querySelectorAll(sel) || []; |
||||
|
} |
||||
|
|
||||
|
function $(sel, el) { |
||||
|
if (el) { |
||||
|
return el.querySelector(sel); |
||||
|
} |
||||
|
return document.body.querySelector(sel); |
||||
|
} |
||||
|
|
||||
|
function clearAuths() { |
||||
|
var _auths = { |
||||
|
google: { |
||||
|
promise: null, |
||||
|
idToken: '' |
||||
|
} |
||||
|
}; |
||||
|
_auths.google.promise = new Promise(function (res, rej) { |
||||
|
_auths.google.resolve = res; |
||||
|
_auths.google.reject = rej; |
||||
|
}); |
||||
|
return _auths; |
||||
|
} |
||||
|
|
||||
|
window.onSignIn = async function onSignIn(googleUser) { |
||||
|
// Useful data for your client-side scripts:
|
||||
|
var profile = googleUser.getBasicProfile(); |
||||
|
// Don't send this directly to your server!
|
||||
|
console.log('ID: ' + profile.getId()); |
||||
|
console.log('Full Name: ' + profile.getName()); |
||||
|
console.log('Given Name: ' + profile.getGivenName()); |
||||
|
console.log('Family Name: ' + profile.getFamilyName()); |
||||
|
console.log('Image URL: ' + profile.getImageUrl()); |
||||
|
console.log('Email: ' + profile.getEmail()); |
||||
|
|
||||
|
// The ID token you need to pass to your backend:
|
||||
|
auths.google.idToken = googleUser.getAuthResponse().id_token; |
||||
|
console.log('ID Token: ' + auths.google.idToken); |
||||
|
auths.google.resolve(auths.google.idToken); |
||||
|
}; |
||||
|
|
||||
|
function setFlow(cont, flow) { |
||||
|
$$(cont).forEach(function (el) { |
||||
|
el.hidden = true; |
||||
|
}); |
||||
|
console.log(flow); |
||||
|
$(flow).hidden = false; |
||||
|
} |
||||
|
|
||||
|
async function unlock() { |
||||
|
var key; |
||||
|
try { |
||||
|
key = await PocketId.unlock(function () { |
||||
|
setFlow('.authn-container', '.authn-unlock'); |
||||
|
return new Promise(function (resolve, reject) { |
||||
|
window.unlocker = { resolve: resolve, reject: reject }; |
||||
|
}); |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
console.error( |
||||
|
"Had a key, but couldn't unlock it. TODO: Just send email?" |
||||
|
); |
||||
|
console.error(e); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setFlow('.authn-container', '.authn-loading'); |
||||
|
|
||||
|
if (key) { |
||||
|
genTokenWithKey(key); |
||||
|
return; |
||||
|
await PocketId.createIdToken({ key: key }); |
||||
|
} |
||||
|
|
||||
|
PocketId.signIdToken(id_token).then(function (resp) { |
||||
|
console.log('Response:'); |
||||
|
console.log(resp); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function genTokenWithKey() { |
||||
|
// TODO: generate token
|
||||
|
// TODO: check if the key is still considered valid
|
||||
|
// TODO: generate new key and authorize
|
||||
|
} |
||||
|
|
||||
|
(async function () { |
||||
|
var loc = window.location; |
||||
|
|
||||
|
console.log('/new-hashcash?'); |
||||
|
var resp = await request({ |
||||
|
method: 'POST', |
||||
|
url: loc.protocol + '//' + loc.hostname + '/api/new-hashcash' |
||||
|
}); |
||||
|
console.log(resp); |
||||
|
|
||||
|
console.log('/test-hashcash?'); |
||||
|
resp = await request({ |
||||
|
method: 'POST', |
||||
|
url: loc.protocol + '//' + loc.hostname + '/api/test-hashcash' |
||||
|
}); |
||||
|
console.log(resp); |
||||
|
})(); |
||||
|
|
||||
|
setFlow('.authn-container', '.authn-email'); |
||||
|
|
||||
|
$('.authn-email form').addEventListener('submit', function (ev) { |
||||
|
ev.preventDefault(); |
||||
|
ev.stopPropagation(); |
||||
|
state.email = $('.authn-email [name=username]').value; |
||||
|
|
||||
|
setFlow('.authn-container', '.authn-loading'); |
||||
|
return PocketId.auth |
||||
|
.meta({ email: state.email }) |
||||
|
.catch(function (err) { |
||||
|
window.alert('Error: ' + err.message); |
||||
|
}) |
||||
|
.then(function (resp) { |
||||
|
// if the user exists, go to the continue screen
|
||||
|
// otherwise go to the new user screen
|
||||
|
console.log('meta:', resp); |
||||
|
if (!resp.body.success) { |
||||
|
// This is a completely new user
|
||||
|
setFlow('.authn-container', '.authn-new-user'); |
||||
|
return; |
||||
|
} |
||||
|
// The user exists, but this is a new device
|
||||
|
setFlow('.authn-container', '.authn-existing'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
$('.authn-new-user form').addEventListener('submit', function (ev) { |
||||
|
ev.preventDefault(); |
||||
|
ev.stopPropagation(); |
||||
|
|
||||
|
// We don't need to worry about checking if the key exists
|
||||
|
// even if it does, the account has been deactivated
|
||||
|
|
||||
|
setFlow('.authn-container', '.authn-loading'); |
||||
|
return PocketId.auth |
||||
|
.verify({ scheme: 'mailto:', email: state.email }) |
||||
|
.catch(function (err) { |
||||
|
window.alert('Error: ' + err.message); |
||||
|
}) |
||||
|
.then(function (resp) { |
||||
|
console.log(resp); |
||||
|
localStorage.setItem( |
||||
|
'pocketid', // + state.email,
|
||||
|
JSON.stringify({ |
||||
|
receipt: resp.body.receipt, |
||||
|
email: state.email, |
||||
|
createdAt: new Date().toISOString() |
||||
|
}) |
||||
|
); |
||||
|
window.alert("Go check yo' email!"); |
||||
|
return PocketId.auth |
||||
|
.consume({ |
||||
|
email: state.email, |
||||
|
receipt: resp.body.receipt |
||||
|
}) |
||||
|
.then(function (resp) { |
||||
|
window.alert('all set!'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
var route = window.location.hash.split('/').slice(1); |
||||
|
console.log('route:', route); |
||||
|
switch (route[0]) { |
||||
|
case 'verify': |
||||
|
var pstate = JSON.parse(localStorage.getItem('pocketid') || '{}'); |
||||
|
PocketId.auth |
||||
|
.consume({ |
||||
|
receipt: pstate.receipt, |
||||
|
secret: route[1] |
||||
|
}) |
||||
|
.then(function (resp) { |
||||
|
console.log('token for this device to save:', resp); |
||||
|
window.alert('goodness!'); |
||||
|
}) |
||||
|
.catch(function (e) { |
||||
|
console.error(e); |
||||
|
window.alert('network error, try again'); |
||||
|
}); |
||||
|
break; |
||||
|
default: |
||||
|
// do nothing
|
||||
|
} |
@ -0,0 +1,34 @@ |
|||||
|
{ |
||||
|
"name": "pocketid", |
||||
|
"version": "0.1.0", |
||||
|
"description": "ID tokens made easy", |
||||
|
"main": "pocketid.js", |
||||
|
"scripts": { |
||||
|
"prettier": "prettier --write '**/*.{css,js,md}'", |
||||
|
"test": "node pocketid_test.js" |
||||
|
}, |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "https://example.com/pocketid.git" |
||||
|
}, |
||||
|
"keywords": [ |
||||
|
"oauth1", |
||||
|
"oauth2", |
||||
|
"oauth3", |
||||
|
"oidc", |
||||
|
"acme", |
||||
|
"jwt", |
||||
|
"jose", |
||||
|
"jws", |
||||
|
"jwk" |
||||
|
], |
||||
|
"author": "AJ ONeal <coolaj86@gmail.com>", |
||||
|
"license": "MPL-2.0", |
||||
|
"dependencies": { |
||||
|
"@root/keypairs": "^0.10.1" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"webpack": "^5.0.0-beta.28", |
||||
|
"webpack-cli": "^3.3.12" |
||||
|
} |
||||
|
} |
@ -0,0 +1,118 @@ |
|||||
|
'use strict'; |
||||
|
|
||||
|
var Keypairs = require('@root/keypairs'); |
||||
|
var PocketId = module.exports; |
||||
|
var request = require('./request.js'); |
||||
|
|
||||
|
var keyJson = window.localStorage.getItem('private.jwk.json'); |
||||
|
|
||||
|
PocketId.signIdToken = async function (idToken) { |
||||
|
var pair = await Keypairs.parseOrGenerate({ key: keyJson }); |
||||
|
var jwt = await Keypairs.signJwt({ |
||||
|
jwk: pair.private, |
||||
|
iss: window.location.protocol + '//' + window.location.hostname, |
||||
|
exp: '15m', |
||||
|
claims: { |
||||
|
contact: ['google:' + idToken] |
||||
|
} |
||||
|
}); |
||||
|
return jwt; |
||||
|
}; |
||||
|
|
||||
|
PocketId.auth = {}; |
||||
|
PocketId.auth.meta = async function ({ email }) { |
||||
|
var loc = window.location; |
||||
|
var body = await request({ |
||||
|
method: 'GET', |
||||
|
url: |
||||
|
loc.protocol + |
||||
|
'//' + |
||||
|
loc.hostname + |
||||
|
'/api/authn/meta?contact=' + |
||||
|
'mailto:' + |
||||
|
email |
||||
|
}); |
||||
|
return body; |
||||
|
}; |
||||
|
|
||||
|
PocketId.auth.verify = async function ({ scheme, email }) { |
||||
|
if (!scheme) { |
||||
|
scheme = 'mailto:'; |
||||
|
} |
||||
|
|
||||
|
var loc = window.location; |
||||
|
var body = await request({ |
||||
|
method: 'GET', |
||||
|
url: |
||||
|
loc.protocol + |
||||
|
'//' + |
||||
|
loc.hostname + |
||||
|
'/api/authn/verify?contact=' + |
||||
|
scheme + |
||||
|
email |
||||
|
}); |
||||
|
|
||||
|
return body; |
||||
|
}; |
||||
|
|
||||
|
PocketId.auth.consume = async function ({ |
||||
|
email = '', |
||||
|
receipt = '', |
||||
|
secret = '', |
||||
|
count = 0 |
||||
|
}) { |
||||
|
var loc = window.location; |
||||
|
var resp = await request({ |
||||
|
method: 'GET', |
||||
|
url: |
||||
|
loc.protocol + |
||||
|
'//' + |
||||
|
loc.hostname + |
||||
|
'/api/authn/consume?contact=' + |
||||
|
(email ? 'mailto:' + email : '') + |
||||
|
'&receipt=' + |
||||
|
receipt + |
||||
|
'&secret=' + |
||||
|
secret |
||||
|
}); |
||||
|
|
||||
|
if (resp.body.success) { |
||||
|
// There should be a token here
|
||||
|
// (or the pubkey should have been given beforehand)
|
||||
|
return resp.body; |
||||
|
} |
||||
|
|
||||
|
if (resp.body.error) { |
||||
|
// TODO special errors are hard failures
|
||||
|
} |
||||
|
|
||||
|
if (count > 600) { |
||||
|
throw new Error('abandoned login'); |
||||
|
} |
||||
|
|
||||
|
return timeout(5000).then(function () { |
||||
|
console.log('check otp again'); |
||||
|
return PocketId.auth.consume({ |
||||
|
email, |
||||
|
secret, |
||||
|
receipt, |
||||
|
count: count || 0 |
||||
|
}); |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
async function timeout(ms) { |
||||
|
return new Promise(function (resolve) { |
||||
|
setTimeout(resolve, ms); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
var textEncoder = new TextEncoder(); |
||||
|
PocketId.genKey = async function ({ email }) { |
||||
|
// Ideally we'd use PBKDF2 or better but... web standards...
|
||||
|
// TODO put a random salt
|
||||
|
var emailU8 = textEncoder.encode(email); |
||||
|
var salt = await crypto.subtle.digest('SHA-256', emailU8); |
||||
|
var u8 = textEncoder.encode(answer); |
||||
|
var hash = await crypto.subtle.digest('SHA-256', u8); |
||||
|
}; |
@ -0,0 +1,85 @@ |
|||||
|
'use strict'; |
||||
|
|
||||
|
var Hashcash = require('./hashcash.js'); |
||||
|
var _sites = {}; |
||||
|
|
||||
|
module.exports = async function (opts) { |
||||
|
if (!opts.headers) { |
||||
|
opts.headers = {}; |
||||
|
} |
||||
|
|
||||
|
if (opts.json) { |
||||
|
if (true === opts.json) { |
||||
|
opts.body = JSON.stringify(opts.body); |
||||
|
} else { |
||||
|
opts.body = JSON.stringify(opts.json); |
||||
|
} |
||||
|
if (!opts.headers['Content-Type'] && !opts.headers['content-type']) { |
||||
|
opts.headers['Content-Type'] = 'application/json'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!opts.mode) { |
||||
|
opts.mode = 'cors'; |
||||
|
} |
||||
|
|
||||
|
var url = new URL(opts.url); |
||||
|
if (!_sites[url.hostname]) { |
||||
|
_sites[url.hostname] = { nonces: [] }; |
||||
|
} |
||||
|
|
||||
|
var site = _sites[url.hostname]; |
||||
|
var hc = site.hashcashChallenge; |
||||
|
if (hc) { |
||||
|
delete site.hashcashChallenge; |
||||
|
site.hashcash = await Hashcash.solve(hc); |
||||
|
} |
||||
|
if (site.hashcash) { |
||||
|
opts.headers.Hashcash = site.hashcash; |
||||
|
} |
||||
|
|
||||
|
var response = await window.fetch(opts.url, opts); |
||||
|
var headerNames = response.headers.keys(); |
||||
|
var hs = {}; |
||||
|
var h; |
||||
|
while (true) { |
||||
|
h = headerNames.next(); |
||||
|
if (h.done) { |
||||
|
break; |
||||
|
} |
||||
|
hs[h.value] = response.headers.get(h.value); |
||||
|
} |
||||
|
|
||||
|
var body; |
||||
|
if (hs['content-type'].includes('application/json')) { |
||||
|
body = await response.json(); |
||||
|
} else { |
||||
|
body = await response.text(); |
||||
|
try { |
||||
|
body = JSON.parse(body); |
||||
|
} catch (e) { |
||||
|
// ignore
|
||||
|
} |
||||
|
} |
||||
|
var resp = {}; |
||||
|
|
||||
|
resp.body = body; |
||||
|
resp.headers = hs; |
||||
|
resp.toJSON = function () { |
||||
|
return { |
||||
|
headers: hs, |
||||
|
body: body |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
if (resp.headers['hashcash-challenge']) { |
||||
|
_sites[url.hostname].hashcashChallenge = |
||||
|
resp.headers['hashcash-challenge']; |
||||
|
} |
||||
|
if (resp.headers.nonce) { |
||||
|
site.nonces.push(resp.headers.nonce); |
||||
|
} |
||||
|
|
||||
|
console.log(resp); |
||||
|
return resp; |
||||
|
}; |
@ -0,0 +1,19 @@ |
|||||
|
'use strict'; |
||||
|
|
||||
|
var path = require('path'); |
||||
|
|
||||
|
module.exports = { |
||||
|
entry: './main.js', |
||||
|
mode: 'development', |
||||
|
devServer: { |
||||
|
contentBase: path.join(__dirname, 'dist'), |
||||
|
port: 3001 |
||||
|
}, |
||||
|
output: { |
||||
|
publicPath: 'http://localhost:3001/' |
||||
|
}, |
||||
|
module: { |
||||
|
rules: [{}] |
||||
|
}, |
||||
|
plugins: [] |
||||
|
}; |
@ -0,0 +1,4 @@ |
|||||
|
old.go |
||||
|
old_test.go |
||||
|
file.txt |
||||
|
user_agents.txt |
@ -0,0 +1,21 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2017 Miloš Mileusnić |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
@ -0,0 +1,97 @@ |
|||||
|
# Go/Golang package for parsing user agent strings [![GoDoc](https://godoc.org/github.com/mileusna/useragent?status.svg)](https://godoc.org/github.com/mileusna/useragent) |
||||
|
|
||||
|
Package `ua.Parse(userAgent string)` function parses browser's and bot's user agents strings and determins: |
||||
|
+ User agent name and version (Chrome, Firefox, Googlebot, etc.) |
||||
|
+ Operating system name and version (Windows, Android, iOS etc.) |
||||
|
+ Device type (mobile, desktop, tablet, bot) |
||||
|
+ Device name if available (iPhone, iPad, Huawei VNS-L21) |
||||
|
+ URL provided by the bot (http://www.google.com/bot.html etc.) |
||||
|
|
||||
|
## Status |
||||
|
|
||||
|
Still need some work on detecting Andorid device names. |
||||
|
|
||||
|
## Installation <a id="installation"></a> |
||||
|
``` |
||||
|
go get github.com/mileusna/useragent |
||||
|
``` |
||||
|
|
||||
|
## Example<a id="example"></a> |
||||
|
|
||||
|
```go |
||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
|
||||
|
"github.com/mileusna/useragent" |
||||
|
) |
||||
|
|
||||
|
func main() { |
||||
|
userAgents := []string{ |
||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8", |
||||
|
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36", |
||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.0 Mobile/14F89 Safari/602.1", |
||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) FxiOS/8.1.1b4948 Mobile/14F89 Safari/603.2.4", |
||||
|
"Mozilla/5.0 (iPad; CPU OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.0 Mobile/14F89 Safari/602.1", |
||||
|
"Mozilla/5.0 (Linux; Android 4.3; GT-I9300 Build/JSS15J) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.125 Mobile Safari/537.36", |
||||
|
"Mozilla/5.0 (Android 4.3; Mobile; rv:54.0) Gecko/54.0 Firefox/54.0", |
||||
|
"Mozilla/5.0 (Linux; Android 4.3; GT-I9300 Build/JSS15J) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36 OPR/42.9.2246.119956", |
||||
|
"Opera/9.80 (Android; Opera Mini/28.0.2254/66.318; U; en) Presto/2.12.423 Version/12.16", |
||||
|
} |
||||
|
|
||||
|
for _, s := range userAgents { |
||||
|
ua := ua.Parse(s) |
||||
|
fmt.Println() |
||||
|
fmt.Println(ua.String) |
||||
|
fmt.Println(strings.Repeat("=", len(ua.String))) |
||||
|
fmt.Println("Name:", ua.Name, "v", ua.Version) |
||||
|
fmt.Println("OS:", ua.OS, "v", ua.OSVersion) |
||||
|
fmt.Println("Device:", ua.Device) |
||||
|
if ua.Mobile { |
||||
|
fmt.Println("(Mobile)") |
||||
|
} |
||||
|
if ua.Tablet { |
||||
|
fmt.Println("(Tablet)") |
||||
|
} |
||||
|
if ua.Desktop { |
||||
|
fmt.Println("(Desktop)") |
||||
|
} |
||||
|
if ua.Bot { |
||||
|
fmt.Println("(Bot)") |
||||
|
} |
||||
|
if ua.URL != "" { |
||||
|
fmt.Println(ua.URL) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
``` |
||||
|
|
||||
|
## Shorthand functions |
||||
|
|
||||
|
Beside `UserAgent{}` struct and its properties returned by `ua.Parse()`, there is a bunch of shorthand functions for most popular browsers and operating systems, so this code: |
||||
|
|
||||
|
```go |
||||
|
ua := ua.Parse(userAgentString) |
||||
|
if ua.OS == "Android" && ua.Name == "Chrome" { |
||||
|
// do something |
||||
|
} |
||||
|
``` |
||||
|
can be also written on this way: |
||||
|
```go |
||||
|
ua := ua.Parse(userAgentString) |
||||
|
if ua.IsAndroid() && ua.IsChrome() { |
||||
|
// do something |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Notice |
||||
|
|
||||
|
+ Opera and Opera Mini are two browsers, since they operate on very different ways. |
||||
|
+ If Googlebot (or any other bot) is detected and it is using its mobile crawler, both `bot` and `mobile` flags will be set to `true`. |
||||
|
|
||||
|
|
||||
|
|
@ -0,0 +1,3 @@ |
|||||
|
module github.com/mileusna/useragent |
||||
|
|
||||
|
go 1.14 |
@ -0,0 +1,76 @@ |
|||||
|
package ua |
||||
|
|
||||
|
// IsWindows shorthand function to check if OS == Windows
|
||||
|
func (ua UserAgent) IsWindows() bool { |
||||
|
return ua.OS == Windows |
||||
|
} |
||||
|
|
||||
|
// IsAndroid shorthand function to check if OS == Android
|
||||
|
func (ua UserAgent) IsAndroid() bool { |
||||
|
return ua.OS == Android |
||||
|
} |
||||
|
|
||||
|
// IsMacOS shorthand function to check if OS == MacOS
|
||||
|
func (ua UserAgent) IsMacOS() bool { |
||||
|
return ua.OS == MacOS |
||||
|
} |
||||
|
|
||||
|
// IsIOS shorthand function to check if OS == IOS
|
||||
|
func (ua UserAgent) IsIOS() bool { |
||||
|
return ua.OS == IOS |
||||
|
} |
||||
|
|
||||
|
// IsLinux shorthand function to check if OS == Linux
|
||||
|
func (ua UserAgent) IsLinux() bool { |
||||
|
return ua.OS == Linux |
||||
|
} |
||||
|
|
||||
|
// IsOpera shorthand function to check if Name == Opera
|
||||
|
func (ua UserAgent) IsOpera() bool { |
||||
|
return ua.Name == Opera |
||||
|
} |
||||
|
|
||||
|
// IsOperaMini shorthand function to check if Name == Opera Mini
|
||||
|
func (ua UserAgent) IsOperaMini() bool { |
||||
|
return ua.Name == OperaMini |
||||
|
} |
||||
|
|
||||
|
// IsChrome shorthand function to check if Name == Chrome
|
||||
|
func (ua UserAgent) IsChrome() bool { |
||||
|
return ua.Name == Chrome |
||||
|
} |
||||
|
|
||||
|
// IsFirefox shorthand function to check if Name == Firefox
|
||||
|
func (ua UserAgent) IsFirefox() bool { |
||||
|
return ua.Name == Firefox |
||||
|
} |
||||
|
|
||||
|
// IsInternetExplorer shorthand function to check if Name == Internet Explorer
|
||||
|
func (ua UserAgent) IsInternetExplorer() bool { |
||||
|
return ua.Name == InternetExplorer |
||||
|
} |
||||
|
|
||||
|
// IsSafari shorthand function to check if Name == Safari
|
||||
|
func (ua UserAgent) IsSafari() bool { |
||||
|
return ua.Name == Safari |
||||
|
} |
||||
|
|
||||
|
// IsEdge shorthand function to check if Name == Edge
|
||||
|
func (ua UserAgent) IsEdge() bool { |
||||
|
return ua.Name == Edge |
||||
|
} |
||||
|
|
||||
|
// IsGooglebot shorthand function to check if Name == Googlebot
|
||||
|
func (ua UserAgent) IsGooglebot() bool { |
||||
|
return ua.Name == Googlebot |
||||
|
} |
||||
|
|
||||
|
// IsTwitterbot shorthand function to check if Name == Twitterbot
|
||||
|
func (ua UserAgent) IsTwitterbot() bool { |
||||
|
return ua.Name == Twitterbot |
||||
|
} |
||||
|
|
||||
|
// IsFacebookbot shorthand function to check if Name == FacebookExternalHit
|
||||
|
func (ua UserAgent) IsFacebookbot() bool { |
||||
|
return ua.Name == FacebookExternalHit |
||||
|
} |
@ -0,0 +1,434 @@ |
|||||
|
package ua |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"regexp" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
// UserAgent struct containg all determined datra from parsed user-agent string
|
||||
|
type UserAgent struct { |
||||
|
Name string |
||||
|
Version string |
||||
|
OS string |
||||
|
OSVersion string |
||||
|
Device string |
||||
|
Mobile bool |
||||
|
Tablet bool |
||||
|
Desktop bool |
||||
|
Bot bool |
||||
|
URL string |
||||
|
String string |
||||
|
} |
||||
|
|
||||
|
var ignore = map[string]struct{}{ |
||||
|
"KHTML, like Gecko": struct{}{}, |
||||
|
"U": struct{}{}, |
||||
|
"compatible": struct{}{}, |
||||
|
"Mozilla": struct{}{}, |
||||
|
"WOW64": struct{}{}, |
||||
|
} |
||||
|
|
||||
|
// Constants for browsers and operating systems for easier comparation
|
||||
|
const ( |
||||
|
Windows = "Windows" |
||||
|
WindowsPhone = "Windows Phone" |
||||
|
Android = "Android" |
||||
|
MacOS = "macOS" |
||||
|
IOS = "iOS" |
||||
|
Linux = "Linux" |
||||
|
|
||||
|
Opera = "Opera" |
||||
|
OperaMini = "Opera Mini" |
||||
|
OperaTouch = "Opera Touch" |
||||
|
Chrome = "Chrome" |
||||
|
Firefox = "Firefox" |
||||
|
InternetExplorer = "Internet Explorer" |
||||
|
Safari = "Safari" |
||||
|
Edge = "Edge" |
||||
|
Vivaldi = "Vivaldi" |
||||
|
|
||||
|
Googlebot = "Googlebot" |
||||
|
Twitterbot = "Twitterbot" |
||||
|
FacebookExternalHit = "facebookexternalhit" |
||||
|
Applebot = "Applebot" |
||||
|
) |
||||
|
|
||||
|
// Parse user agent string returning UserAgent struct
|
||||
|
func Parse(userAgent string) UserAgent { |
||||
|
ua := UserAgent{ |
||||
|
String: userAgent, |
||||
|
} |
||||
|
|
||||
|
tokens := parse(userAgent) |
||||
|
|
||||
|
// check is there URL
|
||||
|
for k := range tokens { |
||||
|
if strings.HasPrefix(k, "http://") || strings.HasPrefix(k, "https://") { |
||||
|
ua.URL = k |
||||
|
delete(tokens, k) |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// OS lookup
|
||||
|
switch { |
||||
|
case tokens.exists("Android"): |
||||
|
ua.OS = Android |
||||
|
ua.OSVersion = tokens[Android] |
||||
|
for s := range tokens { |
||||
|
if strings.HasSuffix(s, "Build") { |
||||
|
ua.Device = strings.TrimSpace(s[:len(s)-5]) |
||||
|
ua.Tablet = strings.Contains(strings.ToLower(ua.Device), "tablet") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
case tokens.exists("iPhone"): |
||||
|
ua.OS = IOS |
||||
|
ua.OSVersion = tokens.findMacOSVersion() |
||||
|
ua.Device = "iPhone" |
||||
|
ua.Mobile = true |
||||
|
|
||||
|
case tokens.exists("iPad"): |
||||
|
ua.OS = IOS |
||||
|
ua.OSVersion = tokens.findMacOSVersion() |
||||
|
ua.Device = "iPad" |
||||
|
ua.Tablet = true |
||||
|
|
||||
|
case tokens.exists("Windows NT"): |
||||
|
ua.OS = Windows |
||||
|
ua.OSVersion = tokens["Windows NT"] |
||||
|
ua.Desktop = true |
||||
|
|
||||
|
case tokens.exists("Windows Phone OS"): |
||||
|
ua.OS = WindowsPhone |
||||
|
ua.OSVersion = tokens["Windows Phone OS"] |
||||
|
ua.Mobile = true |
||||
|
|
||||
|
case tokens.exists("Macintosh"): |
||||
|
ua.OS = MacOS |
||||
|
ua.OSVersion = tokens.findMacOSVersion() |
||||
|
ua.Desktop = true |
||||
|
|
||||
|
case tokens.exists("Linux"): |
||||
|
ua.OS = Linux |
||||
|
ua.OSVersion = tokens[Linux] |
||||
|
ua.Desktop = true |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// for s, val := range sys {
|
||||
|
// fmt.Println(s, "--", val)
|
||||
|
// }
|
||||
|
|
||||
|
switch { |
||||
|
|
||||
|
case tokens.exists("Googlebot"): |
||||
|
ua.Name = Googlebot |
||||
|
ua.Version = tokens[Googlebot] |
||||
|
ua.Bot = true |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens.exists("Applebot"): |
||||
|
ua.Name = Applebot |
||||
|
ua.Version = tokens[Applebot] |
||||
|
ua.Bot = true |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
ua.OS = "" |
||||
|
|
||||
|
case tokens["Opera Mini"] != "": |
||||
|
ua.Name = OperaMini |
||||
|
ua.Version = tokens[OperaMini] |
||||
|
ua.Mobile = true |
||||
|
|
||||
|
case tokens["OPR"] != "": |
||||
|
ua.Name = Opera |
||||
|
ua.Version = tokens["OPR"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["OPT"] != "": |
||||
|
ua.Name = OperaTouch |
||||
|
ua.Version = tokens["OPT"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
// Opera on iOS
|
||||
|
case tokens["OPiOS"] != "": |
||||
|
ua.Name = Opera |
||||
|
ua.Version = tokens["OPiOS"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
// Chrome on iOS
|
||||
|
case tokens["CriOS"] != "": |
||||
|
ua.Name = Chrome |
||||
|
ua.Version = tokens["CriOS"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
// Firefox on iOS
|
||||
|
case tokens["FxiOS"] != "": |
||||
|
ua.Name = Firefox |
||||
|
ua.Version = tokens["FxiOS"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["Firefox"] != "": |
||||
|
ua.Name = Firefox |
||||
|
ua.Version = tokens[Firefox] |
||||
|
_, ua.Mobile = tokens["Mobile"] |
||||
|
_, ua.Tablet = tokens["Tablet"] |
||||
|
|
||||
|
case tokens["Vivaldi"] != "": |
||||
|
ua.Name = Vivaldi |
||||
|
ua.Version = tokens[Vivaldi] |
||||
|
|
||||
|
case tokens.exists("MSIE"): |
||||
|
ua.Name = InternetExplorer |
||||
|
ua.Version = tokens["MSIE"] |
||||
|
|
||||
|
case tokens["EdgiOS"] != "": |
||||
|
ua.Name = Edge |
||||
|
ua.Version = tokens["EdgiOS"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["Edge"] != "": |
||||
|
ua.Name = Edge |
||||
|
ua.Version = tokens["Edge"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["Edg"] != "": |
||||
|
ua.Name = Edge |
||||
|
ua.Version = tokens["Edg"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["EdgA"] != "": |
||||
|
ua.Name = Edge |
||||
|
ua.Version = tokens["EdgA"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["bingbot"] != "": |
||||
|
ua.Name = "Bingbot" |
||||
|
ua.Version = tokens["bingbot"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens["SamsungBrowser"] != "": |
||||
|
ua.Name = "Samsung Browser" |
||||
|
ua.Version = tokens["SamsungBrowser"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
// if chrome and Safari defined, find any other tokensent descr
|
||||
|
case tokens.exists(Chrome) && tokens.exists(Safari): |
||||
|
name := tokens.findBestMatch(true) |
||||
|
if name != "" { |
||||
|
ua.Name = name |
||||
|
ua.Version = tokens[name] |
||||
|
break |
||||
|
} |
||||
|
fallthrough |
||||
|
|
||||
|
case tokens.exists("Chrome"): |
||||
|
ua.Name = Chrome |
||||
|
ua.Version = tokens["Chrome"] |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
case tokens.exists("Safari"): |
||||
|
ua.Name = Safari |
||||
|
if v, ok := tokens["Version"]; ok { |
||||
|
ua.Version = v |
||||
|
} else { |
||||
|
ua.Version = tokens["Safari"] |
||||
|
} |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
|
||||
|
default: |
||||
|
if ua.OS == "Android" && tokens["Version"] != "" { |
||||
|
ua.Name = "Android browser" |
||||
|
ua.Version = tokens["Version"] |
||||
|
ua.Mobile = true |
||||
|
} else { |
||||
|
if name := tokens.findBestMatch(false); name != "" { |
||||
|
ua.Name = name |
||||
|
ua.Version = tokens[name] |
||||
|
} else { |
||||
|
ua.Name = ua.String |
||||
|
} |
||||
|
ua.Bot = strings.Contains(strings.ToLower(ua.Name), "bot") |
||||
|
ua.Mobile = tokens.existsAny("Mobile", "Mobile Safari") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// if tabler, switch mobile to off
|
||||
|
if ua.Tablet { |
||||
|
ua.Mobile = false |
||||
|
} |
||||
|
|
||||
|
// if not already bot, check some popular bots and weather URL is set
|
||||
|
if !ua.Bot { |
||||
|
ua.Bot = ua.URL != "" |
||||
|
} |
||||
|
|
||||
|
if !ua.Bot { |
||||
|
switch ua.Name { |
||||
|
case Twitterbot, FacebookExternalHit: |
||||
|
ua.Bot = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ua |
||||
|
} |
||||
|
|
||||
|
func parse(userAgent string) (clients properties) { |
||||
|
clients = make(map[string]string, 0) |
||||
|
slash := false |
||||
|
isURL := false |
||||
|
var buff, val bytes.Buffer |
||||
|
addToken := func() { |
||||
|
if buff.Len() != 0 { |
||||
|
s := strings.TrimSpace(buff.String()) |
||||
|
if _, ign := ignore[s]; !ign { |
||||
|
if isURL { |
||||
|
s = strings.TrimPrefix(s, "+") |
||||
|
} |
||||
|
|
||||
|
if val.Len() == 0 { // only if value don't exists
|
||||
|
var ver string |
||||
|
s, ver = checkVer(s) // determin version string and split
|
||||
|
clients[s] = ver |
||||
|
} else { |
||||
|
clients[s] = strings.TrimSpace(val.String()) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
buff.Reset() |
||||
|
val.Reset() |
||||
|
slash = false |
||||
|
isURL = false |
||||
|
} |
||||
|
|
||||
|
parOpen := false |
||||
|
|
||||
|
bua := []byte(userAgent) |
||||
|
for i, c := range bua { |
||||
|
|
||||
|
//fmt.Println(string(c), c)
|
||||
|
switch { |
||||
|
case c == 41: // )
|
||||
|
addToken() |
||||
|
parOpen = false |
||||
|
|
||||
|
case parOpen && c == 59: // ;
|
||||
|
addToken() |
||||
|
|
||||
|
case c == 40: // (
|
||||
|
addToken() |
||||
|
parOpen = true |
||||
|
|
||||
|
case slash && c == 32: |
||||
|
addToken() |
||||
|
|
||||
|
case slash: |
||||
|
val.WriteByte(c) |
||||
|
|
||||
|
case c == 47 && !isURL: // /
|
||||
|
if i != len(bua)-1 && bua[i+1] == 47 && (bytes.HasSuffix(buff.Bytes(), []byte("http:")) || bytes.HasSuffix(buff.Bytes(), []byte("https:"))) { |
||||
|
buff.WriteByte(c) |
||||
|
isURL = true |
||||
|
} else { |
||||
|
slash = true |
||||
|
} |
||||
|
|
||||
|
default: |
||||
|
buff.WriteByte(c) |
||||
|
} |
||||
|
} |
||||
|
addToken() |
||||
|
|
||||
|
return clients |
||||
|
} |
||||
|
|
||||
|
func checkVer(s string) (name, v string) { |
||||
|
i := strings.LastIndex(s, " ") |
||||
|
if i == -1 { |
||||
|
return s, "" |
||||
|
} |
||||
|
|
||||
|
//v = s[i+1:]
|
||||
|
|
||||
|
switch s[:i] { |
||||
|
case "Linux", "Windows NT", "Windows Phone OS", "MSIE", "Android": |
||||
|
return s[:i], s[i+1:] |
||||
|
default: |
||||
|
return s, "" |
||||
|
} |
||||
|
|
||||
|
// for _, c := range v {
|
||||
|
// if (c >= 48 && c <= 57) || c == 46 {
|
||||
|
// } else {
|
||||
|
// return s, ""
|
||||
|
// }
|
||||
|
// }
|
||||
|
|
||||
|
// return s[:i], s[i+1:]
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
type properties map[string]string |
||||
|
|
||||
|
func (p properties) exists(key string) bool { |
||||
|
_, ok := p[key] |
||||
|
return ok |
||||
|
} |
||||
|
|
||||
|
func (p properties) existsAny(keys ...string) bool { |
||||
|
for _, k := range keys { |
||||
|
if _, ok := p[k]; ok { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
func (p properties) findMacOSVersion() string { |
||||
|
for k, v := range p { |
||||
|
if strings.Contains(k, "OS") { |
||||
|
if ver := findVersion(v); ver != "" { |
||||
|
return ver |
||||
|
} else if ver = findVersion(k); ver != "" { |
||||
|
return ver |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// findBestMatch from the rest of the bunch
|
||||
|
// in first cycle only return key vith version value
|
||||
|
// if withVerValue is false, do another cycle and return any token
|
||||
|
func (p properties) findBestMatch(withVerOnly bool) string { |
||||
|
n := 2 |
||||
|
if withVerOnly { |
||||
|
n = 1 |
||||
|
} |
||||
|
for i := 0; i < n; i++ { |
||||
|
for k, v := range p { |
||||
|
switch k { |
||||
|
case Chrome, Firefox, Safari, "Version", "Mobile", "Mobile Safari", "Mozilla", "AppleWebKit", "Windows NT", "Windows Phone OS", Android, "Macintosh", Linux, "GSA": |
||||
|
default: |
||||
|
if i == 0 { |
||||
|
if v != "" { // in first check, only return keys with value
|
||||
|
return k |
||||
|
} |
||||
|
} else { |
||||
|
return k |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
var rxMacOSVer = regexp.MustCompile("[_\\d\\.]+") |
||||
|
|
||||
|
func findVersion(s string) string { |
||||
|
if ver := rxMacOSVer.FindString(s); ver != "" { |
||||
|
return strings.Replace(ver, "_", ".", -1) |
||||
|
} |
||||
|
return "" |
||||
|
} |
Loading…
Reference in new issue