diff --git a/README.md b/README.md index f69302c..24a44ce 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ upload content to https://gist.github.com/. brew install gist +‌For FreeBSD, gist lives in ports + + pkg install gist + +<200c>For Ubuntu/Debian + + apt install gist + +Note: Debian renames the binary to `gist-paste` to avoid a name conflict. + ## Command ‌To upload the contents of `a.rb` just: @@ -79,12 +89,29 @@ To read a gist and print it to STDOUT ## Login -If you want to associate your gists with your GitHub account, you need to login -with gist. It doesn't store your username and password, it just uses them to get -an OAuth2 token (with the "gist" permission). +Before you use `gist` for the first time you will need to log in. There are two supported login flows: + +1. The Github device-code Oauth flow. This is the default for authenticating to github.com, and can be enabled for Github Enterprise by creating an Oauth app, and exporting the environment variable `GIST_CLIENT_ID` with the client id of the Oauth app. +2. The (deprecated) username and password token exchange flow. This is the default for GitHub Enterprise, and can be used to log into github.com by exporting the environment variable `GIST_USE_USERNAME_AND_PASSWORD`. + +### The device-code flow + +This flow allows you to obtain a token by logging into GitHub in the browser and typing a verification code. This is the preferred mechanism. gist --login - Obtaining OAuth2 access_token from github. + Requesting login parameters... + Please sign in at https://github.com/login/device + and enter code: XXXX-XXXX + Success! https://github.com/settings/connections/applications/4f7ec0d4eab38e74384e + +The returned access_token is stored in `~/.gist` and used for all future gisting. If you need to you can revoke access from https://github.com/settings/connections/applications/4f7ec0d4eab38e74384e. + +### The username-password flow + +This flow asks for your GitHub username and password (and 2FA code), and exchanges them for a token with the "gist" permission (your username and password are not stored). This mechanism is deprecated by GitHub, but may still work with GitHub Enterprise. + + gist --login + Obtaining OAuth2 access_token from GitHub. GitHub username: ConradIrwin GitHub password: 2-factor auth code: @@ -94,9 +121,21 @@ This token is stored in `~/.gist` and used for all future gisting. If you need t you can revoke it from https://github.com/settings/tokens, or just delete the file. -‌After you've done this, you can still upload gists anonymously with `-a`. +#### Password-less login + +If you have a complicated authorization requirement you can manually create a +token file by pasting a GitHub token with `gist` scope (and maybe the `user:email` +for GitHub Enterprise) into a file called `~/.gist`. You can create one from +https://github.com/settings/tokens + +This file should contain only the token (~40 hex characters), and to make it +easier to edit, can optionally have a final newline (`\n` or `\r\n`). + +For example, one way to create this file would be to run: + + (umask 0077 && echo MY_SECRET_TOKEN > ~/.gist) - gist -a a.rb +The `umask` ensures that the file is only accessible from your user account. ### GitHub Enterprise @@ -106,10 +145,10 @@ you need to export the `GITHUB_URL` environment variable (usually done in your ` export GITHUB_URL=http://github.internal.example.com/ Once you've done this and restarted your terminal (or run `source ~/.bashrc`), gist will -automatically use github enterprise instead of the public github.com +automatically use GitHub Enterprise instead of the public github.com Your token for GitHub Enterprise will be stored in `.gist..[.]` (e.g. -`~.gist.http.github.internal.example.com` for the GITHUB_URL example above) instead of `~/.gist`. +`~/.gist.http.github.internal.example.com` for the GITHUB_URL example above) instead of `~/.gist`. If you have multiple servers or use Enterprise and public GitHub often, you can work around this by creating scripts that set the env var and then run `gist`. Keep in mind that to use the public GitHub you must unset the env var. Just @@ -137,11 +176,10 @@ If you need more advanced features you can also pass: * `:public` if you want your gist to have a guessable url. * `:description` to add a description to your gist. * `:update` to update an existing gist (can be a URL or an id). -* `:anonymous` to submit an anonymous gist (default is false). * `:copy` to copy the resulting URL to the clipboard (default is false). * `:open` to open the resulting URL in a browser (default is false). -NOTE: The access_token must have the "gist" scope. +NOTE: The access_token must have the `gist` scope and may also require the `user:email` scope. ‌If you want to upload multiple files in the same gist, you can: diff --git a/bin/gist b/bin/gist index c88f623..f513187 100755 --- a/bin/gist +++ b/bin/gist @@ -24,16 +24,12 @@ specified STDIN will be read. The default filename for STDIN is "a.rb", and all filenames can be overridden by repeating the "-f" flag. The most useful reason to do this is to change the syntax highlighting. -If you'd like your gists to be associated with your GitHub account, so that you -can edit them and find them in future, first use `gist --login` to obtain an -Oauth2 access token. This is stored and used by gist in the future. +All gists must to be associated with a GitHub account, so you will need to login with +`gist --login` to obtain an OAuth2 access token. This is stored and used by gist in the future. Private gists do not have guessable URLs and can be created with "-p", you can also set the description at the top of the gist by passing "-d". -Anonymous gists are not associated with your GitHub account, they can be created -with "-a" even after you have used "gist --login". - If you would like to shorten the resulting gist URL, use the -s flag. This will use GitHub's URL shortener, git.io. You can also use -R to get the link to the raw gist. @@ -47,7 +43,11 @@ Instead of creating a new gist, you can update an existing one by passing its ID or URL with "-u". For this to work, you must be logged in, and have created the original gist with the same GitHub account. -Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] [-f NAME|-t EXT]* FILE* +If you want to skip empty files, use the --skip-empty flag. If all files are +empty no gist will be created. + +Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-u URL] + [--skip-empty] [-P] [-f NAME|-t EXT]* FILE* #{executable_name} --login #{executable_name} [-l|-r] @@ -88,10 +88,6 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] options[:update] = update end - opts.on("-a", "--anonymous", "Create an anonymous gist.") do - options[:anonymous] = true - end - opts.on("-c", "--copy", "Copy the resulting URL to the clipboard") do options[:copy] = true end @@ -107,6 +103,10 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] opts.on("--no-open") + opts.on("--skip-empty", "Skip gisting empty files") do + options[:skip_empty] = true + end + opts.on("-P", "--paste", "Paste from the clipboard to gist") do options[:paste] = true end @@ -140,6 +140,12 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] end.parse! begin + if Gist.auth_token.nil? + puts 'Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + exit(1) + end + options[:output] = if options[:embed] && options[:shorten] raise Gist::Error, "--embed does not make sense with --shorten" elsif options[:embed] @@ -167,7 +173,8 @@ begin if options.key? :read file_name = ARGV.first - Gist.read_gist(options[:read], file_name) + output = Gist.read_gist(options[:read], file_name) + puts output if output exit end @@ -195,7 +202,8 @@ begin end end - puts Gist.multi_gist(files, options) + output = Gist.multi_gist(files, options) + puts output if output end rescue Gist::Error => e diff --git a/build/gist b/build/gist index d137968..40fb605 100755 --- a/build/gist +++ b/build/gist @@ -52,7 +52,7 @@ module JSON /(?=\*/) # single slash before this comment's end )* \*/ # the End of this comment - |[ \t\r\n]+ # whitespaces: space, horicontal tab, lf, cr + |[ \t\r\n]+ # whitespaces: space, horizontal tab, lf, cr )+ )mx @@ -72,7 +72,7 @@ module JSON # (keys) in a JSON object. Otherwise strings are returned, which is also # the default. # * *create_additions*: If set to false, the Parser doesn't create - # additions even if a matchin class and create_id was found. This option + # additions even if a matching class and create_id was found. This option # defaults to true. # * *object_class*: Defaults to Hash # * *array_class*: Defaults to Array @@ -1318,23 +1318,27 @@ end module Gist extend self - VERSION = '4.6.0' + VERSION = '6.0.0' # A list of clipboard commands with copy and paste support. CLIPBOARD_COMMANDS = { + 'pbcopy' => 'pbpaste', 'xclip' => 'xclip -o', 'xsel -i' => 'xsel -o', - 'pbcopy' => 'pbpaste', - 'putclip' => 'getclip' + 'putclip' => 'getclip', } GITHUB_API_URL = URI("https://api.github.com/") - GIT_IO_URL = URI("http://git.io/") + GITHUB_URL = URI("https://github.com/") + GIT_IO_URL = URI("https://git.io/") GITHUB_BASE_PATH = "" GHE_BASE_PATH = "/api/v3" + GITHUB_CLIENT_ID = '4f7ec0d4eab38e74384e' + URL_ENV_NAME = "GITHUB_URL" + CLIENT_ID_ENV_NAME = "GIST_CLIENT_ID" USER_AGENT = "gist/#{VERSION} (Net::HTTP, #{RUBY_DESCRIPTION})" @@ -1350,7 +1354,7 @@ module Gist module AuthTokenFile def self.filename if ENV.key?(URL_ENV_NAME) - File.expand_path "~/.gist.#{ENV[URL_ENV_NAME].gsub(/[^a-z.]/, '')}" + File.expand_path "~/.gist.#{ENV[URL_ENV_NAME].gsub(/:/, '.').gsub(/[^a-z0-9.-]/, '')}" else File.expand_path "~/.gist" end @@ -1382,10 +1386,14 @@ module Gist # # @see http://developer.github.com/v3/gists/ def gist(content, options = {}) - filename = options[:filename] || "a.rb" + filename = options[:filename] || default_filename multi_gist({filename => content}, options) end + def default_filename + "gistfile1.txt" + end + # Upload a gist to https://gist.github.com # # @param [Hash] files the code you'd like to gist: filename => content @@ -1398,6 +1406,7 @@ module Gist # @option options [String] :update the URL or id of a gist to update # @option options [Boolean] :copy (false) Copy resulting URL to clipboard, if successful. # @option options [Boolean] :open (false) Open the resulting URL in a browser. + # @option options [Boolean] :skip_empty (false) Skip gisting empty files. # @option options [Symbol] :output (:all) The type of return value you'd like: # :html_url gives a String containing the url to the gist in a browser # :short_url gives a String contianing a git.io url that redirects to html_url @@ -1409,6 +1418,13 @@ module Gist # # @see http://developer.github.com/v3/gists/ def multi_gist(files, options={}) + if options[:anonymous] + raise 'Anonymous gists are no longer supported. Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + else + access_token = (options[:access_token] || auth_token()) + end + json = {} json[:description] = options[:description] if options[:description] @@ -1416,22 +1432,23 @@ module Gist json[:files] = {} files.each_pair do |(name, content)| - raise "Cannot gist empty files" if content.to_s.strip == "" - json[:files][File.basename(name)] = {:content => content} + if content.to_s.strip == "" + raise "Cannot gist empty files" unless options[:skip_empty] + else + name = name == "-" ? default_filename : File.basename(name) + json[:files][name] = {:content => content} + end end + return if json[:files].empty? && options[:skip_empty] + existing_gist = options[:update].to_s.split("/").last - if options[:anonymous] - access_token = nil - else - access_token = (options[:access_token] || auth_token()) - end url = "#{base_path}/gists" url << "/" << CGI.escape(existing_gist) if existing_gist.to_s != '' - url << "?access_token=" << CGI.escape(access_token) if access_token.to_s != '' request = Net::HTTP::Post.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' request.body = JSON.dump(json) request.content_type = 'application/json' @@ -1467,9 +1484,10 @@ module Gist if user == "" access_token = auth_token() if access_token.to_s != '' - url << "/gists?access_token=" << CGI.escape(access_token) + url << "/gists" request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" response = http(api_url, request) pretty_gist(response) @@ -1492,24 +1510,21 @@ module Gist url = "#{base_path}" if user == "" - access_token = auth_token() - if access_token.to_s != '' - url << "/gists?per_page=100&access_token=" << CGI.escape(access_token) - get_gist_pages(url) - else - raise Error, "Not authenticated. Use 'gist --login' to login or 'gist -l username' to view public gists." - end - + url << "/gists?per_page=100" else url << "/users/#{user}/gists?per_page=100" - get_gist_pages(url) end + get_gist_pages(url, auth_token()) end def read_gist(id, file_name=nil) url = "#{base_path}/gists/#{id}" + + access_token = auth_token() + request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' response = http(api_url, request) if response.code == '200' @@ -1535,9 +1550,8 @@ module Gist access_token = auth_token() if access_token.to_s != '' - url << "?access_token=" << CGI.escape(access_token) - request = Net::HTTP::Delete.new(url) + request["Authorization"] = "token #{access_token}" response = http(api_url, request) else raise Error, "Not authenticated. Use 'gist --login' to login." @@ -1550,9 +1564,10 @@ module Gist end end - def get_gist_pages(url) + def get_gist_pages(url, access_token = "") request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' response = http(api_url, request) pretty_gist(response) @@ -1560,7 +1575,7 @@ module Gist if link_header links = Hash[ link_header.gsub(/(<|>|")/, "").split(',').map { |link| link.split('; rel=') } ].invert - get_gist_pages(links['next']) if links['next'] + get_gist_pages(links['next'], access_token) if links['next'] end end @@ -1590,10 +1605,12 @@ module Gist # @param [String] url # @return [String] shortened url, or long url if shortening fails def shorten(url) - request = Net::HTTP::Post.new("/") + request = Net::HTTP::Post.new("/create") request.set_form_data(:url => url) response = http(GIT_IO_URL, request) case response.code + when "200" + URI.join(GIT_IO_URL, response.body).to_s when "201" response['Location'] else @@ -1622,16 +1639,72 @@ module Gist # Log the user into gist. # + def login!(credentials={}) + if (login_url == GITHUB_URL || ENV.key?(CLIENT_ID_ENV_NAME)) && credentials.empty? && !ENV.key?('GIST_USE_USERNAME_AND_PASSWORD') + device_flow_login! + else + access_token_login!(credentials) + end + end + + def device_flow_login! + puts "Requesting login parameters..." + request = Net::HTTP::Post.new("/login/device/code") + request.body = JSON.dump({ + :scope => 'gist', + :client_id => client_id, + }) + request.content_type = 'application/json' + request['accept'] = "application/json" + response = http(login_url, request) + + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + + body = JSON.parse(response.body) + + puts "Please sign in at #{body['verification_uri']}" + puts " and enter code: #{body['user_code']}" + device_code = body['device_code'] + interval = body['interval'] + + loop do + sleep(interval.to_i) + request = Net::HTTP::Post.new("/login/oauth/access_token") + request.body = JSON.dump({ + :client_id => client_id, + :grant_type => 'urn:ietf:params:oauth:grant-type:device_code', + :device_code => device_code + }) + request.content_type = 'application/json' + request['Accept'] = 'application/json' + response = http(login_url, request) + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + body = JSON.parse(response.body) + break unless body['error'] == 'authorization_pending' + end + + if body['error'] + raise Error, body['error_description'] + end + + AuthTokenFile.write JSON.parse(response.body)['access_token'] + + puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/connections/applications/#{client_id}" + end + + # Logs the user into gist. + # # This method asks the user for a username and password, and tries to obtain # and OAuth2 access token, which is then stored in ~/.gist # # @raise [Gist::Error] if something went wrong - # @param [Hash] credentials login details - # @option credentials [String] :username - # @option credentials [String] :password # @see http://developer.github.com/v3/oauth/ - def login!(credentials={}) - puts "Obtaining OAuth2 access_token from github." + def access_token_login!(credentials={}) + puts "Obtaining OAuth2 access_token from GitHub." loop do print "GitHub username: " username = credentials[:username] || $stdin.gets.strip @@ -1666,7 +1739,7 @@ module Gist if Net::HTTPCreated === response AuthTokenFile.write JSON.parse(response.body)['token'] - puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/applications" + puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/tokens" return elsif Net::HTTPUnauthorized === response puts "Error: #{JSON.parse(response.body)['message']}" @@ -1687,7 +1760,11 @@ module Gist env = ENV['http_proxy'] || ENV['HTTP_PROXY'] connection = if env proxy = URI(env) - Net::HTTP::Proxy(proxy.host, proxy.port).new(uri.host, uri.port) + if proxy.user + Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password).new(uri.host, uri.port) + else + Net::HTTP::Proxy(proxy.host, proxy.port).new(uri.host, uri.port) + end else Net::HTTP.new(uri.host, uri.port) end @@ -1841,11 +1918,19 @@ Could not find copy command, tried: ENV.key?(URL_ENV_NAME) ? GHE_BASE_PATH : GITHUB_BASE_PATH end + def login_url + ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_URL + end + # Get the API URL def api_url ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_API_URL end + def client_id + ENV.key?(CLIENT_ID_ENV_NAME) ? URI(ENV[CLIENT_ID_ENV_NAME]) : GITHUB_CLIENT_ID + end + def legacy_private_gister? return unless which('git') `git config --global gist.private` =~ /\Ayes|1|true|on\z/i @@ -1884,16 +1969,12 @@ specified STDIN will be read. The default filename for STDIN is "a.rb", and all filenames can be overridden by repeating the "-f" flag. The most useful reason to do this is to change the syntax highlighting. -If you'd like your gists to be associated with your GitHub account, so that you -can edit them and find them in future, first use `gist --login` to obtain an -Oauth2 access token. This is stored and used by gist in the future. +All gists must to be associated with a GitHub account, so you will need to login with +`gist --login` to obtain an OAuth2 access token. This is stored and used by gist in the future. Private gists do not have guessable URLs and can be created with "-p", you can also set the description at the top of the gist by passing "-d". -Anonymous gists are not associated with your GitHub account, they can be created -with "-a" even after you have used "gist --login". - If you would like to shorten the resulting gist URL, use the -s flag. This will use GitHub's URL shortener, git.io. You can also use -R to get the link to the raw gist. @@ -1907,7 +1988,11 @@ Instead of creating a new gist, you can update an existing one by passing its ID or URL with "-u". For this to work, you must be logged in, and have created the original gist with the same GitHub account. -Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] [-f NAME|-t EXT]* FILE* +If you want to skip empty files, use the --skip-empty flag. If all files are +empty no gist will be created. + +Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-u URL] + [--skip-empty] [-P] [-f NAME|-t EXT]* FILE* #{executable_name} --login #{executable_name} [-l|-r] @@ -1948,10 +2033,6 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] options[:update] = update end - opts.on("-a", "--anonymous", "Create an anonymous gist.") do - options[:anonymous] = true - end - opts.on("-c", "--copy", "Copy the resulting URL to the clipboard") do options[:copy] = true end @@ -1967,6 +2048,10 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] opts.on("--no-open") + opts.on("--skip-empty", "Skip gisting empty files") do + options[:skip_empty] = true + end + opts.on("-P", "--paste", "Paste from the clipboard to gist") do options[:paste] = true end @@ -2000,6 +2085,12 @@ Usage: #{executable_name} [-o|-c|-e] [-p] [-s] [-R] [-d DESC] [-a] [-u URL] [-P] end.parse! begin + if Gist.auth_token.nil? + puts 'Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + exit(1) + end + options[:output] = if options[:embed] && options[:shorten] raise Gist::Error, "--embed does not make sense with --shorten" elsif options[:embed] @@ -2055,7 +2146,8 @@ begin end end - puts Gist.multi_gist(files, options) + output = Gist.multi_gist(files, options) + puts output if output end rescue Gist::Error => e diff --git a/build/gist.1 b/build/gist.1 index 3a77667..850c556 100644 --- a/build/gist.1 +++ b/build/gist.1 @@ -1,7 +1,7 @@ .\" generated with Ronn/v0.7.3 .\" http://github.com/rtomayko/ronn/tree/0.7.3 . -.TH "GIST" "1" "May 2017" "" "Gist manual" +.TH "GIST" "1" "August 2020" "" "Gist manual" . .SH "NAME" \fBgist\fR \- upload code to https://gist\.github\.com @@ -29,6 +29,12 @@ For OS X, gist lives in Homebrew .IP brew install gist . +.IP "\(bu" 4 +For FreeBSD, gist lives in ports +. +.IP +pkg install gist +. .IP "" 0 . .SH "Command" @@ -121,14 +127,45 @@ See \fBgist \-\-help\fR for more detail\. .IP "" 0 . .SH "Login" -If you want to associate your gists with your GitHub account, you need to login with gist\. It doesn\'t store your username and password, it just uses them to get an OAuth2 token (with the "gist" permission)\. +Before you use \fBgist\fR for the first time you will need to log in\. There are two supported login flows: +. +.IP "1." 4 +The Github device\-code Oauth flow\. This is the default for authenticating to github\.com, and can be enabled for Github Enterprise by creating an Oauth app, and exporting the environment variable \fBGIST_CLIENT_ID\fR with the client id of the Oauth app\. +. +.IP "2." 4 +The (deprecated) username and password token exchange flow\. This is the default for GitHub Enterprise, and can be used to log into github\.com by exporting the environment variable \fBGIST_USE_USERNAME_AND_PASSWORD\fR\. +. +.IP "" 0 +. +.SS "The device\-code flow" +This flow allows you to obtain a token by logging into GitHub in the browser and typing a verification code\. This is the preferred mechanism\. . .IP "" 4 . .nf gist \-\-login -Obtaining OAuth2 access_token from github\. +Requesting login parameters\.\.\. +Please sign in at https://github\.com/login/device + and enter code: XXXX\-XXXX +Success! https://github\.com/settings/connections/applications/4f7ec0d4eab38e74384e +. +.fi +. +.IP "" 0 +. +.P +The returned access_token is stored in \fB~/\.gist\fR and used for all future gisting\. If you need to you can revoke access from https://github\.com/settings/connections/applications/4f7ec0d4eab38e74384e\. +. +.SS "The username\-password flow" +This flow asks for your GitHub username and password (and 2FA code), and exchanges them for a token with the "gist" permission (your username and password are not stored)\. This mechanism is deprecated by GitHub, but may still work with GitHub Enterprise\. +. +.IP "" 4 +. +.nf + +gist \-\-login +Obtaining OAuth2 access_token from GitHub\. GitHub username: ConradIrwin GitHub password: 2\-factor auth code: @@ -141,14 +178,28 @@ Success! https://github\.com/settings/tokens .P This token is stored in \fB~/\.gist\fR and used for all future gisting\. If you need to you can revoke it from https://github\.com/settings/tokens, or just delete the file\. . -.IP "\(bu" 4 -After you\'ve done this, you can still upload gists anonymously with \fB\-a\fR\. +.P +If you have a complicated authorization requirement you can manually create a token file by pasting a GitHub token with \fBgist\fR scope (and maybe the \fBuser:email\fR for GitHub Enterprise) into a file called \fB~/\.gist\fR\. You can create one from https://github\.com/settings/tokens . -.IP -gist \-a a\.rb +.P +This file should contain only the token (~40 hex characters), and to make it easier to edit, can optionally have a final newline (\fB\en\fR or \fB\er\en\fR)\. +. +.P +For example, one way to create this file would be to run: +. +.IP "" 4 +. +.nf + +(umask 0077 && echo MY_SECRET_TOKEN > ~/\.gist) +. +.fi . .IP "" 0 . +.P +The \fBumask\fR ensures that the file is only accessible from your user account\. +. .SS "GitHub Enterprise" If you\'d like \fBgist\fR to use your locally installed GitHub Enterprise \fIhttps://enterprise\.github\.com/\fR, you need to export the \fBGITHUB_URL\fR environment variable (usually done in your \fB~/\.bashrc\fR)\. . @@ -163,10 +214,10 @@ export GITHUB_URL=http://github\.internal\.example\.com/ .IP "" 0 . .P -Once you\'ve done this and restarted your terminal (or run \fBsource ~/\.bashrc\fR), gist will automatically use github enterprise instead of the public github\.com +Once you\'ve done this and restarted your terminal (or run \fBsource ~/\.bashrc\fR), gist will automatically use GitHub Enterprise instead of the public github\.com . .P -Your token for GitHub Enterprise will be stored in \fB\.gist\.\.[\.]\fR (e\.g\. \fB~\.gist\.http\.github\.internal\.example\.com\fR for the GITHUB_URL example above) instead of \fB~/\.gist\fR\. +Your token for GitHub Enterprise will be stored in \fB\.gist\.\.[\.]\fR (e\.g\. \fB~/\.gist\.http\.github\.internal\.example\.com\fR for the GITHUB_URL example above) instead of \fB~/\.gist\fR\. . .P If you have multiple servers or use Enterprise and public GitHub often, you can work around this by creating scripts that set the env var and then run \fBgist\fR\. Keep in mind that to use the public GitHub you must unset the env var\. Just setting it to the public URL will not work\. Use \fBunset GITHUB_URL\fR @@ -199,9 +250,6 @@ If you need more advanced features you can also pass: \fB:update\fR to update an existing gist (can be a URL or an id)\. . .IP "\(bu" 4 -\fB:anonymous\fR to submit an anonymous gist (default is false)\. -. -.IP "\(bu" 4 \fB:copy\fR to copy the resulting URL to the clipboard (default is false)\. . .IP "\(bu" 4 @@ -210,7 +258,7 @@ If you need more advanced features you can also pass: .IP "" 0 . .P -NOTE: The access_token must have the "gist" scope\. +NOTE: The access_token must have the \fBgist\fR scope and may also require the \fBuser:email\fR scope\. . .IP "\(bu" 4 If you want to upload multiple files in the same gist, you can: diff --git a/gist.gemspec b/gist.gemspec index 3367e55..4d7a2f3 100644 --- a/gist.gemspec +++ b/gist.gemspec @@ -9,7 +9,7 @@ Gem::Specification.new do |s| s.email = ['conrad.irwin@gmail.com', 'rkingist@sharpsaw.org'] s.authors = ['Conrad Irwin', '☈king'] s.license = 'MIT' - s.files = `git ls-files`.split("\n") + s.files = `git ls-files`.split("\n") - Dir.glob("build/*") - [".gitignore"] s.require_paths = ["lib"] s.executables << 'gist' diff --git a/lib/gist.rb b/lib/gist.rb index b52580c..dac1b33 100644 --- a/lib/gist.rb +++ b/lib/gist.rb @@ -12,7 +12,7 @@ module Gist extend self - VERSION = '4.6.0' + VERSION = '6.0.0' # A list of clipboard commands with copy and paste support. CLIPBOARD_COMMANDS = { @@ -23,12 +23,16 @@ module Gist } GITHUB_API_URL = URI("https://api.github.com/") + GITHUB_URL = URI("https://github.com/") GIT_IO_URL = URI("https://git.io/") GITHUB_BASE_PATH = "" GHE_BASE_PATH = "/api/v3" + GITHUB_CLIENT_ID = '4f7ec0d4eab38e74384e' + URL_ENV_NAME = "GITHUB_URL" + CLIENT_ID_ENV_NAME = "GIST_CLIENT_ID" USER_AGENT = "gist/#{VERSION} (Net::HTTP, #{RUBY_DESCRIPTION})" @@ -44,7 +48,7 @@ class ClipboardError < RuntimeError; include Error end module AuthTokenFile def self.filename if ENV.key?(URL_ENV_NAME) - File.expand_path "~/.gist.#{ENV[URL_ENV_NAME].gsub(/:/, '.').gsub(/[^a-z0-9.]/, '')}" + File.expand_path "~/.gist.#{ENV[URL_ENV_NAME].gsub(/:/, '.').gsub(/[^a-z0-9.-]/, '')}" else File.expand_path "~/.gist" end @@ -76,10 +80,14 @@ def auth_token # # @see http://developer.github.com/v3/gists/ def gist(content, options = {}) - filename = options[:filename] || "a.rb" + filename = options[:filename] || default_filename multi_gist({filename => content}, options) end + def default_filename + "gistfile1.txt" + end + # Upload a gist to https://gist.github.com # # @param [Hash] files the code you'd like to gist: filename => content @@ -92,9 +100,10 @@ def gist(content, options = {}) # @option options [String] :update the URL or id of a gist to update # @option options [Boolean] :copy (false) Copy resulting URL to clipboard, if successful. # @option options [Boolean] :open (false) Open the resulting URL in a browser. + # @option options [Boolean] :skip_empty (false) Skip gisting empty files. # @option options [Symbol] :output (:all) The type of return value you'd like: # :html_url gives a String containing the url to the gist in a browser - # :short_url gives a String contianing a git.io url that redirects to html_url + # :short_url gives a String containing a git.io url that redirects to html_url # :javascript gives a String containing a script tag suitable for embedding the gist # :all gives a Hash containing the parsed json response from the server # @@ -103,6 +112,13 @@ def gist(content, options = {}) # # @see http://developer.github.com/v3/gists/ def multi_gist(files, options={}) + if options[:anonymous] + raise 'Anonymous gists are no longer supported. Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + else + access_token = (options[:access_token] || auth_token()) + end + json = {} json[:description] = options[:description] if options[:description] @@ -110,22 +126,23 @@ def multi_gist(files, options={}) json[:files] = {} files.each_pair do |(name, content)| - raise "Cannot gist empty files" if content.to_s.strip == "" - json[:files][File.basename(name)] = {:content => content} + if content.to_s.strip == "" + raise "Cannot gist empty files" unless options[:skip_empty] + else + name = name == "-" ? default_filename : File.basename(name) + json[:files][name] = {:content => content} + end end + return if json[:files].empty? && options[:skip_empty] + existing_gist = options[:update].to_s.split("/").last - if options[:anonymous] - access_token = nil - else - access_token = (options[:access_token] || auth_token()) - end url = "#{base_path}/gists" url << "/" << CGI.escape(existing_gist) if existing_gist.to_s != '' - url << "?access_token=" << CGI.escape(access_token) if access_token.to_s != '' request = Net::HTTP::Post.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' request.body = JSON.dump(json) request.content_type = 'application/json' @@ -161,9 +178,10 @@ def list_gists(user = "") if user == "" access_token = auth_token() if access_token.to_s != '' - url << "/gists?access_token=" << CGI.escape(access_token) + url << "/gists" request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" response = http(api_url, request) pretty_gist(response) @@ -186,24 +204,24 @@ def list_all_gists(user = "") url = "#{base_path}" if user == "" - access_token = auth_token() - if access_token.to_s != '' - url << "/gists?per_page=100&access_token=" << CGI.escape(access_token) - get_gist_pages(url) - else - raise Error, "Not authenticated. Use 'gist --login' to login or 'gist -l username' to view public gists." - end - + url << "/gists?per_page=100" else url << "/users/#{user}/gists?per_page=100" - get_gist_pages(url) end + get_gist_pages(url, auth_token()) end - def read_gist(id, file_name=nil) + def read_gist(id, file_name=nil, options={}) url = "#{base_path}/gists/#{id}" + + access_token = (options[:access_token] || auth_token()) + if access_token.to_s != '' + url << "?access_token=" << CGI.escape(access_token) + end + request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' response = http(api_url, request) if response.code == '200' @@ -217,7 +235,7 @@ def read_gist(id, file_name=nil) file = files.values.first end - puts file["content"] + file["content"] else raise Error, "Gist with id of #{id} does not exist." end @@ -229,9 +247,8 @@ def delete_gist(id) access_token = auth_token() if access_token.to_s != '' - url << "?access_token=" << CGI.escape(access_token) - request = Net::HTTP::Delete.new(url) + request["Authorization"] = "token #{access_token}" response = http(api_url, request) else raise Error, "Not authenticated. Use 'gist --login' to login." @@ -244,9 +261,10 @@ def delete_gist(id) end end - def get_gist_pages(url) + def get_gist_pages(url, access_token = "") request = Net::HTTP::Get.new(url) + request['Authorization'] = "token #{access_token}" if access_token.to_s != '' response = http(api_url, request) pretty_gist(response) @@ -254,7 +272,7 @@ def get_gist_pages(url) if link_header links = Hash[ link_header.gsub(/(<|>|")/, "").split(',').map { |link| link.split('; rel=') } ].invert - get_gist_pages(links['next']) if links['next'] + get_gist_pages(links['next'], access_token) if links['next'] end end @@ -318,16 +336,72 @@ def rawify(url) # Log the user into gist. # + def login!(credentials={}) + if (login_url == GITHUB_URL || ENV.key?(CLIENT_ID_ENV_NAME)) && credentials.empty? && !ENV.key?('GIST_USE_USERNAME_AND_PASSWORD') + device_flow_login! + else + access_token_login!(credentials) + end + end + + def device_flow_login! + puts "Requesting login parameters..." + request = Net::HTTP::Post.new("/login/device/code") + request.body = JSON.dump({ + :scope => 'gist', + :client_id => client_id, + }) + request.content_type = 'application/json' + request['accept'] = "application/json" + response = http(login_url, request) + + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + + body = JSON.parse(response.body) + + puts "Please sign in at #{body['verification_uri']}" + puts " and enter code: #{body['user_code']}" + device_code = body['device_code'] + interval = body['interval'] + + loop do + sleep(interval.to_i) + request = Net::HTTP::Post.new("/login/oauth/access_token") + request.body = JSON.dump({ + :client_id => client_id, + :grant_type => 'urn:ietf:params:oauth:grant-type:device_code', + :device_code => device_code + }) + request.content_type = 'application/json' + request['Accept'] = 'application/json' + response = http(login_url, request) + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + body = JSON.parse(response.body) + break unless body['error'] == 'authorization_pending' + end + + if body['error'] + raise Error, body['error_description'] + end + + AuthTokenFile.write JSON.parse(response.body)['access_token'] + + puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/connections/applications/#{client_id}" + end + + # Logs the user into gist. + # # This method asks the user for a username and password, and tries to obtain # and OAuth2 access token, which is then stored in ~/.gist # # @raise [Gist::Error] if something went wrong - # @param [Hash] credentials login details - # @option credentials [String] :username - # @option credentials [String] :password # @see http://developer.github.com/v3/oauth/ - def login!(credentials={}) - puts "Obtaining OAuth2 access_token from github." + def access_token_login!(credentials={}) + puts "Obtaining OAuth2 access_token from GitHub." loop do print "GitHub username: " username = credentials[:username] || $stdin.gets.strip @@ -383,7 +457,11 @@ def http_connection(uri) env = ENV['http_proxy'] || ENV['HTTP_PROXY'] connection = if env proxy = URI(env) - Net::HTTP::Proxy(proxy.host, proxy.port).new(uri.host, uri.port) + if proxy.user + Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password).new(uri.host, uri.port) + else + Net::HTTP::Proxy(proxy.host, proxy.port).new(uri.host, uri.port) + end else Net::HTTP.new(uri.host, uri.port) end @@ -537,11 +615,19 @@ def base_path ENV.key?(URL_ENV_NAME) ? GHE_BASE_PATH : GITHUB_BASE_PATH end + def login_url + ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_URL + end + # Get the API URL def api_url ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_API_URL end + def client_id + ENV.key?(CLIENT_ID_ENV_NAME) ? URI(ENV[CLIENT_ID_ENV_NAME]) : GITHUB_CLIENT_ID + end + def legacy_private_gister? return unless which('git') `git config --global gist.private` =~ /\Ayes|1|true|on\z/i diff --git a/spec/ghe_spec.rb b/spec/ghe_spec.rb index 5d88a2f..9279915 100644 --- a/spec/ghe_spec.rb +++ b/spec/ghe_spec.rb @@ -5,10 +5,10 @@ MOCK_USER = 'foo' MOCK_PASSWORD = 'bar' - MOCK_AUTHZ_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_USER}:#{MOCK_PASSWORD}@#{MOCK_GHE_HOST}/api/v3/" + MOCK_AUTHZ_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_GHE_HOST}/api/v3/" MOCK_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_GHE_HOST}/api/v3/" - MOCK_AUTHZ_GITHUB_URL = "https://#{MOCK_USER}:#{MOCK_PASSWORD}@api.github.com/" MOCK_GITHUB_URL = "https://api.github.com/" + MOCK_LOGIN_URL = "https://github.com/" before do @saved_env = ENV[Gist::URL_ENV_NAME] @@ -20,8 +20,15 @@ # stub requests for /authorizations stub_request(:post, /#{MOCK_AUTHZ_GHE_URL}authorizations/). to_return(:status => 201, :body => '{"token": "asdf"}') - stub_request(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/). + stub_request(:post, /#{MOCK_GITHUB_URL}authorizations/). + with(headers: {'Authorization'=>'Basic Zm9vOmJhcg=='}). to_return(:status => 201, :body => '{"token": "asdf"}') + + stub_request(:post, /#{MOCK_LOGIN_URL}login\/device\/code/). + to_return(:status => 200, :body => '{"interval": "0.1", "user_code":"XXXX-XXXX", "device_code": "xxxx", "verification_uri": "https://github.com/login/device"}') + + stub_request(:post, /#{MOCK_LOGIN_URL}login\/oauth\/access_token/). + to_return(:status => 200, :body => '{"access_token":"zzzz"}') end after do @@ -48,7 +55,7 @@ Gist.login! - assert_requested(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/) + assert_requested(:post, /#{MOCK_LOGIN_URL}login\/oauth\/access_token/) end it "should access to #{MOCK_GHE_HOST} when $#{Gist::URL_ENV_NAME} was set" do @@ -65,7 +72,7 @@ $stdin = StringIO.new "#{MOCK_USER}_wrong\n#{MOCK_PASSWORD}_wrong\n" Gist.login! :username => MOCK_USER, :password => MOCK_PASSWORD - assert_requested(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/) + assert_requested(:post, /#{MOCK_GITHUB_URL}authorizations/) end end diff --git a/spec/rawify_spec.rb b/spec/rawify_spec.rb index b4703cf..10f25d6 100644 --- a/spec/rawify_spec.rb +++ b/spec/rawify_spec.rb @@ -6,7 +6,16 @@ end it "should return the raw file url" do - Gist.gist("Test gist", :output => :raw_url, :anonymous => true).should == "https://gist.github.com/anonymous/XXXXXX/raw" + Gist.gist("Test gist", :output => :raw_url, :anonymous => false).should == "https://gist.github.com/anonymous/XXXXXX/raw" + end + + it 'should raise an error when trying to do operations without being logged in' do + error_msg = 'Anonymous gists are no longer supported. Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + + expect do + Gist.gist("Test gist", output: :raw_url, anonymous: true) + end.to raise_error(StandardError, error_msg) end end diff --git a/spec/shorten_spec.rb b/spec/shorten_spec.rb index b65d705..a7a0f45 100644 --- a/spec/shorten_spec.rb +++ b/spec/shorten_spec.rb @@ -5,11 +5,20 @@ it "should return a shortened version of the URL when response is 200" do stub_request(:post, "https://git.io/create").to_return(:status => 200, :body => 'XXXXXX') - Gist.gist("Test gist", :output => :short_url, :anonymous => true).should == "https://git.io/XXXXXX" + Gist.gist("Test gist", :output => :short_url, anonymous: false).should == "https://git.io/XXXXXX" end it "should return a shortened version of the URL when response is 201" do stub_request(:post, "https://git.io/create").to_return(:status => 201, :headers => { 'Location' => 'https://git.io/XXXXXX' }) - Gist.gist("Test gist", :output => :short_url, :anonymous => true).should == "https://git.io/XXXXXX" + Gist.gist("Test gist", :output => :short_url, anonymous: false).should == "https://git.io/XXXXXX" + end + + it 'should raise an error when trying to get short urls without being logged in' do + error_msg = 'Anonymous gists are no longer supported. Please log in with `gist --login`. ' \ + '(GitHub now requires credentials to gist https://bit.ly/2GBBxKw)' + + expect do + Gist.gist("Test gist", output: :short_url, anonymous: true) + end.to raise_error(StandardError, error_msg) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 504a277..37dbef0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,7 +11,7 @@ mocks.syntax = :should end config.expect_with :rspec do |expectations| - expectations.syntax = :should + expectations.syntax = [:should, :expect] end end diff --git a/vendor/json.rb b/vendor/json.rb index 578791f..35e060b 100644 --- a/vendor/json.rb +++ b/vendor/json.rb @@ -1265,7 +1265,7 @@ def j(*objs) nil end - # Ouputs _objs_ to STDOUT as JSON strings in a pretty format, with + # Outputs _objs_ to STDOUT as JSON strings in a pretty format, with # indentation and over many lines. def jj(*objs) objs.each do |obj|