├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE.TXT ├── Makefile ├── README.md ├── TODO.txt ├── auth.db ├── cmd ├── root.go ├── server.go └── server_test.go ├── docker ├── Dockerfile └── README.md ├── fileio ├── HtmlFooter.tmpl ├── HtmlHeader.tmpl ├── HtmlIndex.tmpl ├── HtmlView.tmpl ├── HtmlViewErr.tmpl ├── HtmlViewInfo.tmpl ├── custom.css ├── favicon.ico ├── file.go ├── file_test.go ├── gjfy-logo-small.png ├── html.go └── logo.png ├── gjfy-post ├── go.mod ├── go.sum ├── httpio ├── api_handlers.go ├── api_handlers_test.go ├── routes.go ├── static_handlers.go └── view_handlers.go ├── init ├── README.md └── systemd │ └── gjfy.service ├── logo_s.png ├── main.go ├── misc ├── client-sh.go ├── headers.go ├── headers_test.go └── mail.go ├── store ├── store.go └── store_test.go ├── tag-release.sh └── tokendb ├── auth.go └── auth_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: gjfy 5 | 6 | on: 7 | push: 8 | branches: [ "master", "dev" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.24' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | cover.cov 3 | coverage.out 4 | gjfy 5 | gjfy.crt 6 | gjfy.key 7 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | 2 | Copyright © 2016,2017,2018 Sebastian Stark 3 | 4 | Permission to use, copy, modify, and distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the above 6 | copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKGROOT=github.com/sstark/gjfy 2 | VERSION_VAR=cmd.Version 3 | VERSION=$(shell git describe --match="v*") 4 | BIN_NAME=gjfy 5 | 6 | all: build 7 | 8 | build: 9 | go build -ldflags "-s -w -X $(PKGROOT)/$(VERSION_VAR)=$(VERSION)" -o $(BIN_NAME) 10 | 11 | build-debug: 12 | go build -ldflags "-X $(PKGROOT)/$(VERSION_VAR)=$(VERSION)-debug" -o $(BIN_NAME) 13 | 14 | clean: 15 | go clean 16 | 17 | test: 18 | go test ./... 19 | 20 | test-verbose: 21 | go test -v ./... 22 | 23 | test-coverage: 24 | go test -cover ./... 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![gjfy](https://raw.githubusercontent.com/sstark/gjfy/master/fileio/logo.png) 2 | 3 | one time link server 4 | ==================== 5 | 6 | > [!WARNING] 7 | > Changes in development (master) version: 8 | 9 | - Next release 2.0 will be reworked in several ways. Main visible change is 10 | that more functionality will be added to the gjfy binary itself, e. g. posting 11 | secrets. Other additions are planned. 12 | - A recent go release is needed to build. 13 | - Existing functionality has been moved into `server` subcommand. Just use 14 | `gjfy server` wherever you called just `gjfy` before 15 | - Flags have been changed to be posix compatible (-> `--` instead of `-`). Just 16 | add another `-` in front of every option. You may now use short options too, 17 | see help. 18 | - You should be able to build and use the master branch if you follow above 19 | notes, but it has not been tested a lot yet. 20 | 21 | What does it do? 22 | ---------------- 23 | 24 | gjfy is a single binary, standalone web server with only one purpose: Create 25 | links that automatically disappear once clicked. On first click it will show a 26 | "secret", for instance a password that somebody wants to send to someone. 27 | 28 | The idea is that if the original receiver finds the link invalid, they know 29 | that the secret was intercepted by a third party and the sender can reset the 30 | password. This does not protect against eavesdropping attacks, for this you 31 | need a TLS connection. 32 | 33 | There is no persistency: If the server process ends, all secrets are gone. 34 | 35 | Please be careful: Using a tool like gjfy is only advised when all other 36 | options are even less secure (mail, e-mail, phone). In any case, if you send a 37 | password, the receiver should be told to change it as soon as possible. 38 | 39 | What makes it different? 40 | ------------------------ 41 | 42 | There are other tools available that do similar things. However, usually those 43 | involve installing lots of dependencies or web frameworks and often require 44 | setting up a database. Some of them are even offering a hosted service, so 45 | you would be handing your secrets to a third party. 46 | 47 | Gjfy does not need any of this: it is a completely self-contained and 48 | on-premise system. 49 | 50 | Probably the most notable difference is that secrets are only kept in memory. 51 | They are never written into a database or a file. So it can never happen that, 52 | because of a program bug or sysadmin mistake, the secrets are left on the disk. 53 | However, it is possible that the operating system will write part of the 54 | program memory into swap temporarily, which is not easy to avoid. 55 | 56 | The author believes that tools like this should not load assets from external 57 | sources and also that no javascript should be used. Gjfy will never do that and 58 | instead try to be as simple and privacy respecting as possible. 59 | 60 | 61 | Features 62 | -------- 63 | 64 | - Everything in a single binary 65 | - No web server or application server needed 66 | - No database needed 67 | - No persistence 68 | - No javascript 69 | - Simple json API (demo client included) 70 | - Simple html user interface 71 | - The CSS styling, logo and user message can be customised 72 | - Simple token based authentication 73 | - Email notification 74 | 75 | Building 76 | -------- 77 | 78 | A precompiled binary is provided with each release. It is also easy to build 79 | gjfy yourself, in case you prefer that: 80 | 81 | If you do not have a go environment installed already, install it from your 82 | linux distribution repository (e. g. `apt-get install golang-go`) or download 83 | it from the [go home page](https://golang.org/dl/). 84 | 85 | Download the code and run `make`, it will create a single binary file for 86 | easy deployment. 87 | 88 | Installation 89 | ------------ 90 | 91 | Create a directory, e. g. `/usr/local/gjfy`. Then copy the following files to it: 92 | 93 | - gjfy (the binary you just built)1 94 | - auth.db 95 | 96 | For integration into the various system management environments like upstart or 97 | systemd, check the init/ subdirectory for examples. 98 | 99 | 1If you installed a version <=1.2 using `go get`, the binary will be 100 | located at `$GOPATH/bin/gjfy`, while the rest of the files will be under 101 | `$GOPATH/src/github.com/sstark/gjfy` 102 | 103 | Running 104 | ------- 105 | 106 | ### Subcommand `server` 107 | 108 | Choose the IP address and port gjfy listens on with the `-listen` parameter. 109 | 110 | Examples: 111 | 112 | gjfy server --listen '0.0.0.0:1234' # listen on all IPv4 addresses 113 | gjfy server --listen '[::1]:4123' # listen on localhost, IPv6 only 114 | gjfy server --listen ':6234' # listen on all addresses, IPv4 and IPv6 115 | 116 | To tell gjfy its name as seen by users of the service, use the `-urlbase` parameter like so: 117 | 118 | gjfy server --urlbase 'https://gjfy.example.org' 119 | gjfy server --urlbase 'https://gjfy.example.org:4123' 120 | 121 | To use TLS security add the `-tls` switch: 122 | 123 | gjfy server --tls 124 | 125 | The scheme will automatically switch to https unless you set urlbase. Before 126 | you can turn on tls you must create a certificate file called `gjfy.crt` and a 127 | key file called `gjfy.key`. 128 | 129 | Use `gjfy server --help` for help. 130 | 131 | ### Subcommand `completion` 132 | 133 | Use the completion subcommand to generate completion code for one if these shells: bash, fish, powershell, zsh. 134 | 135 | E. g. for bash or zsh you can use it by putting something like this in your 136 | shell startup: 137 | 138 | ```sh 139 | source <(gjfy completion zsh) 140 | ``` 141 | 142 | Options 143 | ------- 144 | 145 | Custom CSS styling can by applied by placing a file "custom.css" in either 146 | `/etc/gjfy/custom.css` or `$PWD/custom.css`. 147 | 148 | An authentication token database should placed in either `/etc/gjfy/auth.db` or 149 | `$PWD/auth.db`. An example file is distributed with the software. New secrets 150 | can only be created with a valid auth token in the POST request. 151 | 152 | If you are using TLS mode you need to put in place either `/etc/gjfy/gjfy.crt` 153 | or `$PWD/gjfy.crt`. Same applies to the key file `gjfy.key`. 154 | 155 | The logo.png can be replaced by a custom logo if needed. (It must be png) 156 | 157 | You may create a file `userMessageView.txt` that will contain the message the 158 | user sees when clicking on the link. It will replace the default message. HTML 159 | can not be used. 160 | 161 | `$PWD/` will take precedence over `/etc/gjfy/` for above options. 162 | 163 | To trigger reloading of auth.db, logo.png, custom.css or userMessageView.txt 164 | you can send SIGHUP to the gjfy process. The TLS certificate or key won't be 165 | reloaded this way. 166 | 167 | Authentication 168 | -------------- 169 | 170 | gjfy has a very simple authentication model. Requests that add tokens are 171 | required to carry an *auth_token* in their json data. This *auth_token* is 172 | looked up in the file `auth.db` and the corresponding email address used for 173 | further processing and notification. If gjfy does not find the provided 174 | auth_token, it will reject the request. 175 | 176 | Authentication is only for adding new secrets. It does not give access to the 177 | secrets itself. 178 | 179 | This authentication model has some downsides and should probably be replaced by 180 | something better. For now just keep in mind that every user in auth.db needs to 181 | have an individual auth_token, because it is used to identify the "user". 182 | 183 | To add an account to `auth.db`, simply edit it using your favorite editor and 184 | add a section to the json list that is contained in it, like this: 185 | 186 | { 187 | "token": "thesecretauthtoken", 188 | "email": "test@example.org" 189 | } 190 | 191 | Afterwards send gjfy a hangup signal (`killall -HUP gjfy`) to make it reload 192 | the file. In the logfile you will be informed about success or failure. 193 | 194 | Usage 195 | ----- 196 | 197 | Currently the only way to create new secrets is by using the json API. An 198 | example client (gjfy-post) is included. A basic request looks like this: 199 | 200 | {"auth_token":"g4uhg3iu4h5i3u4","secret":"someSecret"} 201 | 202 | By sending this to `/api/v1/new` you create a new URL which is a hash over that 203 | json structure. The reply from the server will tell you this link in both, a 204 | user friendly version and in an API version. Invocation of that link will 205 | immediately lead to deletion of the secret in the server. However, there is an 206 | exception: you can post a `"max_clicks":n` variable along with the json and it 207 | will allow up to `n` clicks. 208 | 209 | The authentication token sent with the request will not be stored in the 210 | server. Instead, the associated email address will be stored with the secret, 211 | so it can be used for email notifications (see below). 212 | 213 | A timeout can be set by including `"valid_for:n"` in the request. The secret 214 | will become invalid after n days, even if not clicked. The default timeout is 7 215 | days. 216 | 217 | Email notifications 218 | ------------------- 219 | 220 | To get notified if somebody uses the one time link, add the `-notify` flag to 221 | the gjfy command line. gjfy will use the email address associated with the 222 | authentication token that was used when the secret was generated. 223 | 224 | This requires that the email sub system of the server where gjfy is running is 225 | configured properly. In principle, if it is possible to send an email using the 226 | `mail` command as the gjfy user, email notifications from gjfy should also 227 | work. 228 | 229 | By default, email notification is not enabled. 230 | 231 | gjfy-post 232 | --------- 233 | 234 | gjfy-post is a demonstration client using bash, curl and jq. 235 | 236 | usage: ./gjfy-post [maxclicks] 237 | 238 | Required arguments are authtoken and the secret itself. Please note that 239 | providing the secret this way makes it readable in the system process listing! 240 | 241 | The client can be downloaded from the running server by using the URL 242 | 243 | /gjfy-post 244 | 245 | Which is also linked from the root page ("/"). 246 | 247 | You can change the default URL for gjfy-post by setting the environment 248 | variable `GJFY_POSTURL`. If you downloaded gify-post via the URL, it will 249 | have the correct URL already configured in the script. 250 | 251 | FAQ 252 | --- 253 | 254 | Q: How do you pronounce gjfy? 255 | 256 | A: It is pronounced like "jiffy". 257 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - header.html 2 | - better error handling in url handlers 3 | 4 | # Rework 2.0 5 | 6 | - Use spf13/cobra for command line flags. ✅ 7 | - Single binary with multiple functionality: 8 | * `gjfy server` the current "classic" mode. ✅ 9 | * `gjfy token ` manage user tokens. 10 | * `gjfy secret ` add secrets. Replaces the shell script. 11 | * `gjfy db ` dumps or imports the current secret db from memory. 12 | + Idea is to be able to restart gjfy without losing secrets. 13 | - Consider dropping TLS support. 14 | * This has never been a good feature, and this should be really done by a 15 | reverse proxy with proper certificate management. 16 | * Downside is that for api actions like `gjfy secret` we lose TLS or have 17 | to setup a reverse proxy even for testing. 18 | - Use `go embed` for logo and html template. ✅ 19 | - Split project in packages ✅ 20 | - Fix deprecated io/ioutil usage ✅ 21 | - Improve test coverage 22 | - Use a more standard mechanism for sending token 23 | - Make separate api and user http handlers ✅ 24 | 25 | 26 | # Hash val 27 | 28 | map[hash]entry 29 | 30 | hash1 -> entry1 31 | hash2 -> entry2 32 | . 33 | . 34 | . 35 | 36 | struct_entry | struct_entry 37 | - secret |____ hash --> - hash(secret) 38 | - date | - date 39 | - issuer | - issuer 40 | -------------------------------------------------------------------------------- /auth.db: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "token": "test", 4 | "email": "test@example.org" 5 | }, 6 | { 7 | "token": "test2", 8 | "email": "other@example.org" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | Version string 12 | ) 13 | 14 | var rootCmd = &cobra.Command{ 15 | Use: "gjfy", 16 | Short: "gjfy one-time links", 17 | Version: Version, 18 | Long: ` 19 | gjfy is a web service and tool for creating and providing one-time clickable links`, 20 | } 21 | 22 | func Execute() { 23 | if err := rootCmd.Execute(); err != nil { 24 | fmt.Println(err) 25 | os.Exit(1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/sstark/gjfy/fileio" 15 | "github.com/sstark/gjfy/httpio" 16 | "github.com/sstark/gjfy/misc" 17 | "github.com/sstark/gjfy/store" 18 | "github.com/sstark/gjfy/tokendb" 19 | ) 20 | 21 | const ( 22 | myName = "gjfy" 23 | defaultHostname = "localhost" 24 | listenDefault = ":9154" 25 | expiryCheck = 30 // minutes 26 | crtFile = myName + ".crt" 27 | keyFile = myName + ".key" 28 | TLSDefault = false 29 | notifyDefault = false 30 | allowAnonymousDefault = false 31 | ) 32 | 33 | var ( 34 | auth tokendb.TokenDB 35 | css []byte 36 | logo []byte 37 | updated = time.Time{} 38 | fListen string 39 | fURLBase string 40 | fTLS bool 41 | fNotify bool 42 | fAllowAnonymous bool 43 | scheme = "http://" 44 | userMessageView string 45 | ) 46 | 47 | func Log(handler http.Handler) http.Handler { 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | realRemoteAddr := misc.GetRealIP(r) 50 | log.Printf("%s (%s) \"%s %s %s\" \"%s\"", r.RemoteAddr, realRemoteAddr, r.Method, r.URL.Path, r.Proto, r.Header.Get("User-Agent")) 51 | handler.ServeHTTP(w, r) 52 | }) 53 | } 54 | 55 | func getURLBase() string { 56 | if fURLBase != "" { 57 | return fURLBase 58 | } 59 | sl := strings.Split(fListen, ":") 60 | port := sl[len(sl)-1] 61 | return fmt.Sprintf("%s%s:%s", scheme, defaultHostname, port) 62 | } 63 | 64 | 65 | // updateFiles returns values for the (global) variables for those assets 66 | // the the user can change/update during gjfy runtime. This is supposed 67 | // to be run once at the beginning and on SIGHUP. 68 | // The returned values need to be assigned to the corresponding (global) 69 | // variables. 70 | // The last return value contains the time of the last update. 71 | func updateFiles() (auth tokendb.TokenDB, css, logo []byte, userMessageView string, updated time.Time) { 72 | auth = tokendb.MakeTokenDB(fileio.TryReadFile(tokendb.AuthFileName)) 73 | if auth == nil { 74 | log.Println("auth db could not be loaded, please fix and reload") 75 | } 76 | css = fileio.FileOrFunc(fileio.CssFileName, func(fn string) []byte { 77 | // default to embedded css if file not found 78 | return fileio.CustomCss 79 | }) 80 | logo = fileio.FileOrFunc(fileio.LogoFileName, func(fn string) []byte { 81 | // default to embedded logo if file not found 82 | return fileio.GjfyLogo 83 | }) 84 | userMessageView = fileio.FileOrConst(fileio.UserMessageViewFilename, fileio.UserMessageViewDefaultText) 85 | updated = time.Now() 86 | return 87 | } 88 | 89 | func init() { 90 | rootCmd.AddCommand(serverCmd) 91 | serverCmd.Flags().StringVarP(&fListen, "listen", "l", listenDefault, "Listen on IP:port") 92 | serverCmd.Flags().StringVarP(&fURLBase, "urlbase", "u", "", "Base URL (will be generated by default)") 93 | serverCmd.Flags().BoolVarP(&fTLS, "tls", "s", TLSDefault, "Use TLS connection") 94 | serverCmd.Flags().BoolVarP(&fNotify, "notify", "n", notifyDefault, "Send email notification when one time link is used") 95 | serverCmd.Flags().BoolVarP(&fAllowAnonymous, "allow-anonymous", "a", allowAnonymousDefault, "Allow secrets by anonymous users") 96 | } 97 | 98 | var serverCmd = &cobra.Command{ 99 | Use: "server", 100 | Short: "Run the main gjfy service", 101 | Long: ` 102 | The server subcommand starts the gjfy web service, listening and waiting for 103 | users to retrieve secrets. 104 | 105 | (This is the functionality of gjfy releases <=1.2, which has been moved 106 | into the server subcommand)`, 107 | Run: func(cmd *cobra.Command, args []string) { 108 | log.Printf("gjfy version %s\n", Version) 109 | 110 | memstore := make(store.SecretStore) 111 | memstore.NewEntry("secret", 100, 0, "test@example.org", "test") 112 | go memstore.Expiry(time.Minute * expiryCheck) 113 | 114 | auth, css, logo, userMessageView, updated = updateFiles() 115 | 116 | sighup := make(chan os.Signal, 1) 117 | signal.Notify(sighup, syscall.SIGHUP) 118 | go func() { 119 | for { 120 | <-sighup 121 | log.Println("reloading configuration...") 122 | auth, css, logo, userMessageView, updated = updateFiles() 123 | } 124 | }() 125 | 126 | // View handlers 127 | http.Handle("/", httpio.HandleIndex(fAllowAnonymous)) 128 | http.Handle(httpio.Get, httpio.HandleGet(memstore, getURLBase(), fNotify, &userMessageView)) 129 | http.Handle(httpio.Info, httpio.HandleInfo(memstore, getURLBase())) 130 | if fAllowAnonymous { 131 | http.Handle(httpio.Create, httpio.HandleCreate(memstore, getURLBase())) 132 | } 133 | 134 | // API handlers 135 | http.Handle(httpio.ApiGet, httpio.HandleApiGet(memstore, getURLBase(), fNotify)) 136 | http.Handle(httpio.ApiNew, httpio.HandleApiNew(memstore, getURLBase(), &auth)) 137 | 138 | // Static handlers 139 | http.Handle(httpio.Fav, httpio.HandleStaticFav()) 140 | http.Handle(httpio.LogoSmall, httpio.HandleStaticLogoSmall()) 141 | http.Handle(httpio.Css, httpio.HandleStaticCss(&css, &updated)) 142 | http.Handle(httpio.Logo, httpio.HandleStaticLogo(&logo, &updated)) 143 | http.Handle(httpio.ClientShell, httpio.HandleStaticClientShellScript(getURLBase())) 144 | 145 | if fNotify { 146 | log.Println("email notifications enabled") 147 | } 148 | 149 | if fTLS { 150 | scheme = "https://" 151 | cf := fileio.TryFile(crtFile) 152 | if cf == "" { 153 | log.Fatalf("unable to open %s\n", crtFile) 154 | } 155 | kf := fileio.TryFile(keyFile) 156 | if kf == "" { 157 | log.Fatalf("unable to open %s\n", keyFile) 158 | } 159 | log.Printf("using '%s' as URL base\n", getURLBase()) 160 | log.Println("listening on", fListen, "with TLS") 161 | log.Fatal(http.ListenAndServeTLS(fListen, cf, kf, Log(http.DefaultServeMux))) 162 | } else { 163 | log.Printf("using '%s' as URL base\n", getURLBase()) 164 | log.Println("listening on", fListen, "without TLS") 165 | log.Fatal(http.ListenAndServe(fListen, Log(http.DefaultServeMux))) 166 | } 167 | }, 168 | } 169 | -------------------------------------------------------------------------------- /cmd/server_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "bou.ke/monkey" 14 | "github.com/sstark/gjfy/fileio" 15 | "github.com/sstark/gjfy/httpio" 16 | "github.com/sstark/gjfy/store" 17 | "github.com/sstark/gjfy/tokendb" 18 | ) 19 | 20 | var mockNow = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) 21 | 22 | func createAuthDBFile(dir, content string) { 23 | authDBFile, _ := os.Create(filepath.Join(dir, tokendb.AuthFileName)) 24 | defer authDBFile.Close() 25 | authDBFile.WriteString(content) 26 | } 27 | 28 | func createUserMessageViewFile(dir, content string) { 29 | umvFile, _ := os.Create(filepath.Join(dir, fileio.UserMessageViewFilename)) 30 | defer umvFile.Close() 31 | umvFile.WriteString(content) 32 | } 33 | 34 | func TestUpdateFiles(t *testing.T) { 35 | tmpdir, _ := os.MkdirTemp("", "gjfy_test") 36 | os.Chdir(tmpdir) 37 | createAuthDBFile(tmpdir, `[{ 38 | "token": "test", 39 | "email": "test@example.org" 40 | }, 41 | { 42 | "token": "test2", 43 | "email": "other@example.org" 44 | } 45 | ]`) 46 | auth1, css1, logo1, userMessageView1, updated1 := updateFiles() 47 | time.Sleep(time.Millisecond * 2) 48 | auth2, css2, logo2, userMessageView2, updated2 := updateFiles() 49 | if !reflect.DeepEqual(auth1, auth2) || !reflect.DeepEqual(css1, css2) || !reflect.DeepEqual(logo1, logo2) || userMessageView1 != userMessageView2 { 50 | t.Errorf("Running updateFiles twice gives differing results") 51 | } 52 | if updated1 == updated2 { 53 | t.Errorf("Timestamp did not change between updates") 54 | } 55 | createAuthDBFile(tmpdir, `[{ 56 | "token": "test", 57 | "email": "test@example.org" 58 | }, 59 | { 60 | "token": "test3", 61 | "email": "foobar@example.org" 62 | } 63 | ]`) 64 | auth3, _, _, _, _ := updateFiles() 65 | if reflect.DeepEqual(auth2, auth3) { 66 | t.Errorf("auth.db was not updated after changing file") 67 | } 68 | userMessage := "foo bar baz!" 69 | createUserMessageViewFile(tmpdir, userMessage) 70 | _, _, _, userMessageView3, _ := updateFiles() 71 | if userMessageView3 != userMessage { 72 | t.Errorf("userMessageView was not updated from file") 73 | } 74 | } 75 | 76 | // Try posting a secret without proper token, then update the auth.db 77 | // file with the correct token, reload it and make sure posting works. 78 | func TestAuthDBUpdatedAtRuntime(t *testing.T) { 79 | tmpdir, _ := os.MkdirTemp("", "gjfy_test") 80 | os.Chdir(tmpdir) 81 | createAuthDBFile(tmpdir, `[{ 82 | "token": "test", 83 | "email": "test@example.org" 84 | } 85 | ]`) 86 | auth, _, _, _, _ := updateFiles() 87 | monkey.Patch(time.Now, func() time.Time { 88 | return mockNow 89 | }) 90 | defer monkey.Unpatch(time.Now) 91 | store := make(store.SecretStore) 92 | urlbase := "http://localhost:9154" 93 | postdata := bytes.NewReader([]byte(`{ 94 | "auth_token": "sometoken", 95 | "secret": "sekrit", 96 | "max_clicks": 3 97 | }`)) 98 | req, _ := http.NewRequest("POST", urlbase+httpio.ApiNew, postdata) 99 | rr := httptest.NewRecorder() 100 | handler := httpio.HandleApiNew(store, urlbase, &auth) 101 | handler.ServeHTTP(rr, req) 102 | if status := rr.Code; status != http.StatusUnauthorized { 103 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusUnauthorized) 104 | } 105 | expected := `{"error":"unauthorized"} 106 | ` 107 | if rr.Body.String() != expected { 108 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 109 | rr.Body.String(), expected) 110 | } 111 | createAuthDBFile(tmpdir, `[{ 112 | "token": "test", 113 | "email": "test@example.org" 114 | }, 115 | { 116 | "token": "sometoken", 117 | "email": "other@example.org" 118 | } 119 | ]`) 120 | auth, _, _, _, _ = updateFiles() 121 | postdata2 := bytes.NewReader([]byte(`{ 122 | "auth_token": "sometoken", 123 | "secret": "sekrit", 124 | "max_clicks": 3 125 | }`)) 126 | req2, _ := http.NewRequest("POST", urlbase+httpio.ApiNew, postdata2) 127 | rr2 := httptest.NewRecorder() 128 | handler.ServeHTTP(rr2, req2) 129 | if status := rr2.Code; status != http.StatusCreated { 130 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusCreated) 131 | } 132 | expected2 := `{"secret":"#HIDDEN#","max_clicks":3,"clicks":0,"date_added":"2009-11-10T23:00:00Z","valid_for":7,"auth_token":"other@example.org","id":"5SxjhlIQghCDo4pVktIqVKsti1TGqJ5O9g6eEJMTyhA","path_query":"/g?id=5SxjhlIQghCDo4pVktIqVKsti1TGqJ5O9g6eEJMTyhA","url":"http://localhost:9154/g?id=5SxjhlIQghCDo4pVktIqVKsti1TGqJ5O9g6eEJMTyhA","api_url":"http://localhost:9154/api/v1/get/5SxjhlIQghCDo4pVktIqVKsti1TGqJ5O9g6eEJMTyhA"} 133 | ` 134 | if rr2.Body.String() != expected2 { 135 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", rr2.Body.String(), expected2) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | ARG VERSION="1.2" 3 | RUN adduser --system --home=/home/gjfy gjfy 4 | WORKDIR /tmp 5 | RUN apt-get update 6 | RUN apt-get -y install curl unzip 7 | RUN curl -L -O https://github.com/sstark/gjfy/releases/download/v"$VERSION"/gjfy"$VERSION"-linux-x86_64.zip 8 | RUN unzip gjfy"$VERSION"-linux-x86_64.zip 9 | RUN mkdir /etc/gjfy 10 | RUN mv gjfy/auth.db gjfy/logo.png gjfy/custom.css /etc/gjfy 11 | RUN mv gjfy/gjfy /home/gjfy 12 | USER gjfy:nogroup 13 | ENTRYPOINT ["/home/gjfy/gjfy"] 14 | CMD [""] 15 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | Example Dockerfile for gjfy 2 | =========================== 3 | 4 | Use this Dockerfile as a basis for your own setups. 5 | 6 | Build container for version 1.2: 7 | 8 | docker build --build-arg version=1.2 . -t gjfy 9 | 10 | Run container: 11 | 12 | docker run -d -p 9154:9154 gjfy server 13 | 14 | Add parameters: 15 | 16 | docker run -d -p 9154:9154 gjfy --urlbase https://example.org/ 17 | -------------------------------------------------------------------------------- /fileio/HtmlFooter.tmpl: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 | 3 | 7 | 12 | 13 | 14 | {{end}} 15 | -------------------------------------------------------------------------------- /fileio/HtmlHeader.tmpl: -------------------------------------------------------------------------------- 1 | {{define "header"}} 2 | 3 | 4 | 5 | gjfy{{.}} 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | {{end}} 14 | -------------------------------------------------------------------------------- /fileio/HtmlIndex.tmpl: -------------------------------------------------------------------------------- 1 | 2 | {{define "index"}} 3 | {{template "header" " - one time links"}} 4 |
5 |

gjfy - one time links

6 |

7 | Create links that automatically disappear once clicked. On first click 8 | it will show a "secret", for instance a password that somebody wants to 9 | send to someone.
10 | As a user you normally should not need to visit this index page. 11 |

12 | 19 |

20 |
21 | {{template "footer"}} 22 | {{end}} 23 | -------------------------------------------------------------------------------- /fileio/HtmlView.tmpl: -------------------------------------------------------------------------------- 1 | {{define "view"}} 2 | {{template "header" " - View Secret"}} 3 |
4 |

5 | {{.UserMessageView}} 6 |

7 |

The secret contained in this link is as follows:

8 | 9 |
10 | {{template "footer" .}} 11 | {{end}} 12 | -------------------------------------------------------------------------------- /fileio/HtmlViewErr.tmpl: -------------------------------------------------------------------------------- 1 | {{define "error"}} 2 | {{template "header" " - Error"}} 3 |

Not available

4 |
5 |

This ID is not valid anymore. Please request another one from the person who sent you this link.

6 |
7 | {{template "footer"}} 8 | {{end}} 9 | -------------------------------------------------------------------------------- /fileio/HtmlViewInfo.tmpl: -------------------------------------------------------------------------------- 1 | {{define "info"}} 2 | {{template "header" " - View Metadata"}} 3 |

Metadata for {{.Id}}

4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
IdMaxClicksClicksDateAddedAuthToken
{{.Id}}{{.MaxClicks}}{{.Clicks}}{{.DateAdded}}{{.AuthToken}}
21 |
22 | {{template "footer"}} 23 | {{end}} 24 | -------------------------------------------------------------------------------- /fileio/custom.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | color: #333333; 4 | -webkit-text-size-adjust: 100%; 5 | -ms-text-size-adjust: 100%; 6 | } 7 | body { 8 | margin: 0; 9 | } 10 | 11 | a, 12 | a:active, 13 | a:visited { 14 | color: #999; 15 | text-decoration: none; 16 | } 17 | a:hover { 18 | color: #549aa5; 19 | text-decoration: none; 20 | } 21 | 22 | .gjfy-container { 23 | width: auto; 24 | max-width: 700px; 25 | padding: 0 15px; 26 | margin-right: auto; 27 | margin-left: auto; 28 | text-align: center; 29 | } 30 | 31 | .gjfy-logo { 32 | max-width: 200px; 33 | padding: 80px 0px 20px 0px; 34 | } 35 | 36 | .gjfy-footer-logo-small { 37 | max-height: 24px; 38 | vertical-align: middle; 39 | } 40 | 41 | .text-center { 42 | text-align: center; 43 | } 44 | 45 | .lead { 46 | font-size: 21px; 47 | margin-bottom: 40px; 48 | font-weight: 300; 49 | line-height: 1.4; 50 | } 51 | 52 | div#main ul { 53 | list-style: none; 54 | font-size: 21px; 55 | margin-bottom: 40px; 56 | font-weight: 300; 57 | line-height: 1.4; 58 | } 59 | 60 | .gjfy-form-control { 61 | font-family: inherit; 62 | display: block; 63 | width: 100%; 64 | height: 34px; 65 | padding: 6px 12px; 66 | font-size: 14px; 67 | color: #999 !important; 68 | text-align: center; 69 | line-height: 1.42857143; 70 | color: #555; 71 | background-color: #fff; 72 | background-image: none; 73 | border: 1px solid #ccc; 74 | border-radius: 10px; 75 | -webkit-transition: border-color ease-in-out 0.15s; 76 | -o-transition: border-color ease-in-out 0.15s; 77 | transition: border-color ease-in-out 0.15s; 78 | margin-bottom: 60px; 79 | } 80 | 81 | .create-secret-form-control { 82 | border-radius: 3px; 83 | padding: 5px; 84 | background-color: #fff; 85 | color: #999; 86 | background-image: none; 87 | border: 1px solid #ccc; 88 | } 89 | -------------------------------------------------------------------------------- /fileio/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sstark/gjfy/681717b40cbc09655654f0ec2d5e942a8d38a606/fileio/favicon.ico -------------------------------------------------------------------------------- /fileio/file.go: -------------------------------------------------------------------------------- 1 | package fileio 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path" 7 | ) 8 | 9 | var ( 10 | myName = "gjfy" 11 | configDir = "/etc/" + myName 12 | ) 13 | 14 | // fileOrFunc works like tryReadFile, except it will run a callback 15 | // function and return its result instead of an empty byte slice. 16 | // if the file is not accessible. 17 | // The callback must take a string argument (the filename). 18 | func FileOrFunc(fn string, def func(string) []byte) []byte { 19 | pn := TryReadFile(fn) 20 | if len(pn) > 0 { 21 | return pn 22 | } 23 | return def(fn) 24 | } 25 | 26 | // fileOrConst works like tryReadFile, except it returns a string 27 | // and, if the file is not accessible, a default string. 28 | func FileOrConst(fn string, def string) string { 29 | pn := TryReadFile(fn) 30 | if len(pn) > 0 { 31 | return string(pn) 32 | } 33 | return def 34 | } 35 | 36 | // tryReadFile takes a _filename_ and uses tryFile() to find the file and 37 | // eventually return its contents. If the files was not found or is unreadable 38 | // returns an empty byte slice. 39 | func TryReadFile(fn string) []byte { 40 | pn := TryFile(fn) 41 | contents, err := os.ReadFile(pn) 42 | if err == nil { 43 | return contents 44 | } 45 | return []byte{} 46 | } 47 | 48 | // tryFile takes a _filename_ as an argument and tries several directories to 49 | // find this file. In the case of success it returns the full path name, 50 | // otherwise it returns the empty string. 51 | func TryFile(fn string) string { 52 | var dirs []string 53 | cwd, err := os.Getwd() 54 | if err == nil { 55 | dirs = append(dirs, cwd) 56 | } else { 57 | log.Println("could not get working directory") 58 | } 59 | dirs = append(dirs, configDir) 60 | for _, dir := range dirs { 61 | pn := path.Join(dir, fn) 62 | f, err := os.Open(pn) 63 | if err == nil { 64 | log.Printf("found %s in %s\n", fn, dir) 65 | f.Close() 66 | return pn 67 | } 68 | } 69 | log.Println("could not find", fn) 70 | return "" 71 | } 72 | -------------------------------------------------------------------------------- /fileio/file_test.go: -------------------------------------------------------------------------------- 1 | package fileio 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "path" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestFileOrFunc(t *testing.T) { 13 | log.SetOutput(io.Discard) 14 | byteval := []byte{0x1, 0x2, 0x3, 0x4} 15 | fn := "/this/file/does/not/exist/anywhere.xxxxx" 16 | bytes_returned := FileOrFunc(fn, func(fn string) []byte { 17 | return byteval 18 | }) 19 | if !reflect.DeepEqual(bytes_returned, byteval) { 20 | t.Errorf("FileOrFunc did not fall back properly to the output of the callback function. Got: %v, Expected: %v", bytes_returned, byteval) 21 | } 22 | } 23 | 24 | func TestTryReadFile(t *testing.T) { 25 | tmpdir, _ := os.MkdirTemp("", "gjfy_test") 26 | defer os.Remove(tmpdir) 27 | 28 | fileName := "gjfy_testfile" 29 | testContent := []byte("test") 30 | testFileA := path.Join(tmpdir, fileName) 31 | os.WriteFile(testFileA, testContent, 0644) 32 | defer os.Remove(testFileA) 33 | 34 | configDir = tmpdir 35 | log.SetOutput(io.Discard) 36 | 37 | // Test file in configDir 38 | bytes := TryReadFile(fileName) 39 | if string(bytes) != string(testContent) { 40 | t.Errorf("got %v, wanted %v", bytes, testContent) 41 | } 42 | 43 | // Test file does not exist 44 | bytes = TryReadFile("doesnotexist") 45 | if string(bytes) != "" { 46 | t.Errorf("got %v, wanted empty slice", bytes) 47 | } 48 | 49 | // Test file in pwd shadows file in configDir 50 | testContentPwd := []byte("testPwd") 51 | wd, _ := os.Getwd() 52 | testFileB := path.Join(wd, fileName) 53 | os.WriteFile(testFileB, testContentPwd, 0644) 54 | defer os.Remove(testFileB) 55 | bytes = TryReadFile(fileName) 56 | if string(bytes) != string(testContentPwd) { 57 | t.Errorf("got %v, wanted %v", bytes, testContentPwd) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /fileio/gjfy-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sstark/gjfy/681717b40cbc09655654f0ec2d5e942a8d38a606/fileio/gjfy-logo-small.png -------------------------------------------------------------------------------- /fileio/html.go: -------------------------------------------------------------------------------- 1 | package fileio 2 | 3 | import "embed" 4 | 5 | var ( 6 | //go:embed *.tmpl 7 | HtmlTemplates embed.FS 8 | //go:embed favicon.ico 9 | Favicon []byte 10 | //go:embed gjfy-logo-small.png 11 | GjfyLogoSmall []byte 12 | //go:embed logo.png 13 | GjfyLogo []byte 14 | //go:embed custom.css 15 | CustomCss []byte 16 | ) 17 | 18 | const ( 19 | UserMessageViewDefaultText = ` 20 | The link you invoked contains a secret (a password for example) 21 | somebody wants to share with you. It will be valid only for a short 22 | time and you may not be able to invoke it again. Please make sure 23 | you memorise the secret or write it down in an appropriate way. 24 | ` 25 | UserMessageViewFilename = "userMessageView.txt" 26 | CssFileName = "custom.css" 27 | LogoFileName = "logo.png" 28 | ) 29 | -------------------------------------------------------------------------------- /fileio/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sstark/gjfy/681717b40cbc09655654f0ec2d5e942a8d38a606/fileio/logo.png -------------------------------------------------------------------------------- /gjfy-post: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | POSTURL="${GJFY_POSTURL:-http://localhost:9154/api/v1/new}" 4 | 5 | which jq >/dev/null 2>&1 || { 6 | echo "jq utility not found" >&2 7 | exit 1 8 | } 9 | 10 | if [[ $# -lt 2 ]] 11 | then 12 | echo "usage: $0 " >&2 13 | exit 2 14 | fi 15 | 16 | if [[ -z "$3" ]] 17 | then 18 | postdata="{\"auth_token\":\"$1\",\"secret\":\"$2\"}" 19 | else 20 | postdata="{\"auth_token\":\"$1\",\"secret\":\"$2\",\"max_clicks\":$3}" 21 | fi 22 | curl -s -X POST -d "$postdata" "$POSTURL" | jq -r '.url,.api_url,.error | select (.!=null)' 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sstark/gjfy 2 | 3 | go 1.21 4 | 5 | require ( 6 | bou.ke/monkey v1.0.2 7 | github.com/spf13/cobra v1.9.1 8 | ) 9 | 10 | require ( 11 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 12 | github.com/spf13/pflag v1.0.6 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= 2 | bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 7 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 8 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 9 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 10 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /httpio/api_handlers.go: -------------------------------------------------------------------------------- 1 | package httpio 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | "path" 9 | 10 | "github.com/sstark/gjfy/store" 11 | "github.com/sstark/gjfy/tokendb" 12 | ) 13 | 14 | const ( 15 | maxData = 1048576 // 1MB 16 | ) 17 | 18 | type jsonError struct { 19 | Error string `json:"error"` 20 | } 21 | 22 | func HandleApiGet(memstore store.SecretStore, urlbase string, fNotify bool) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | id := path.Base(r.URL.Path) 25 | if entry, ok := memstore.GetEntryInfo(id, urlbase, Get, ApiGet); !ok { 26 | log.Printf("entry not found: %s", id) 27 | jsonRespond(w, http.StatusNotFound, jsonError{"not found"}) 28 | } else { 29 | memstore.Click(id, r, fNotify) 30 | jsonRespond(w, http.StatusOK, entry) 31 | } 32 | }) 33 | } 34 | 35 | func HandleApiNew(memstore store.SecretStore, urlbase string, auth *tokendb.TokenDB) http.Handler { 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | var entry store.StoreEntry 38 | 39 | body, err := io.ReadAll(io.LimitReader(r.Body, maxData)) 40 | if err != nil { 41 | panic(err) 42 | } 43 | if err := r.Body.Close(); err != nil { 44 | panic(err) 45 | } 46 | if err := json.Unmarshal(body, &entry); err != nil { 47 | log.Printf("error processing json: %s", err) 48 | jsonRespond(w, http.StatusUnprocessableEntity, jsonError{err.Error()}) 49 | } else if !auth.IsAuthorized(&entry) { 50 | log.Printf("unauthorized when trying to make new entry") 51 | jsonRespond(w, http.StatusUnauthorized, jsonError{"unauthorized"}) 52 | } else { 53 | id := memstore.AddEntry(entry, "") 54 | newEntry, _ := memstore.GetEntryInfoHidden(id, urlbase, Get, ApiGet) 55 | log.Println("New ID:", id) 56 | jsonRespond(w, http.StatusCreated, newEntry) 57 | } 58 | }) 59 | } 60 | 61 | func jsonRespond(w http.ResponseWriter, status int, data any) { 62 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 63 | w.WriteHeader(status) 64 | if jerr := json.NewEncoder(w).Encode(data); jerr != nil { 65 | log.Printf("error encoding json: %s", jerr) 66 | _, err := w.Write([]byte(`{"error":"internal error"}`)) 67 | if err != nil { 68 | log.Printf("error writing response: %s", err) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /httpio/api_handlers_test.go: -------------------------------------------------------------------------------- 1 | package httpio 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "bou.ke/monkey" 15 | "github.com/sstark/gjfy/store" 16 | "github.com/sstark/gjfy/tokendb" 17 | ) 18 | 19 | var mockNow = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) 20 | 21 | func TestHandleApiGet(t *testing.T) { 22 | monkey.Patch(time.Now, func() time.Time { 23 | return mockNow 24 | }) 25 | defer monkey.Unpatch(time.Now) 26 | secretStore := make(store.SecretStore) 27 | secretStore.NewEntry("secret", 1, 1, "auth", "testid") 28 | urlbase := "http://localhost:9154" 29 | req, _ := http.NewRequest("GET", urlbase+ApiGet+"testid", nil) 30 | rr := httptest.NewRecorder() 31 | handler := HandleApiGet(secretStore, urlbase, false) 32 | handler.ServeHTTP(rr, req) 33 | if status := rr.Code; status != http.StatusOK { 34 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusOK) 35 | } 36 | expected := `{"secret":"secret","max_clicks":1,"clicks":0,"date_added":"2009-11-10T23:00:00Z","valid_for":1,"auth_token":"auth","id":"testid","path_query":"/g?id=testid","url":"http://localhost:9154/g?id=testid","api_url":"http://localhost:9154/api/v1/get/testid"} 37 | ` 38 | if rr.Body.String() != expected { 39 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 40 | rr.Body.String(), expected) 41 | } 42 | } 43 | 44 | func TestHandleApiGetNonExisting(t *testing.T) { 45 | secretStore := make(store.SecretStore) 46 | urlbase := "http://localhost:9154" 47 | req, _ := http.NewRequest("GET", urlbase+ApiGet+"foo", nil) 48 | rr := httptest.NewRecorder() 49 | handler := HandleApiGet(secretStore, urlbase, false) 50 | handler.ServeHTTP(rr, req) 51 | if status := rr.Code; status != http.StatusNotFound { 52 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusOK) 53 | } 54 | expected := `{"error":"not found"} 55 | ` 56 | if rr.Body.String() != expected { 57 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 58 | rr.Body.String(), expected) 59 | } 60 | } 61 | 62 | func TestHandleApiNew(t *testing.T) { 63 | monkey.Patch(time.Now, func() time.Time { 64 | return mockNow 65 | }) 66 | defer monkey.Unpatch(time.Now) 67 | secretStore := make(store.SecretStore) 68 | auth := tokendb.MakeTokenDB([]byte(`[{ 69 | "token": "footoken", 70 | "email": "test@example.org" 71 | }]`)) 72 | urlbase := "http://localhost:9154" 73 | postdata := bytes.NewReader([]byte(`{ 74 | "auth_token": "footoken", 75 | "secret": "sekrit", 76 | "max_clicks": 3 77 | }`)) 78 | req, _ := http.NewRequest("POST", urlbase+ApiNew, postdata) 79 | rr := httptest.NewRecorder() 80 | handler := HandleApiNew(secretStore, urlbase, &auth) 81 | handler.ServeHTTP(rr, req) 82 | if status := rr.Code; status != http.StatusCreated { 83 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusCreated) 84 | } 85 | expected := `{"secret":"#HIDDEN#","max_clicks":3,"clicks":0,"date_added":"2009-11-10T23:00:00Z","valid_for":7,"auth_token":"test@example.org","id":"EUwXrkDvd1Gw2jNG-gvRr68rGaaNIeJoJOpLQ2WTqNI","path_query":"/g?id=EUwXrkDvd1Gw2jNG-gvRr68rGaaNIeJoJOpLQ2WTqNI","url":"http://localhost:9154/g?id=EUwXrkDvd1Gw2jNG-gvRr68rGaaNIeJoJOpLQ2WTqNI","api_url":"http://localhost:9154/api/v1/get/EUwXrkDvd1Gw2jNG-gvRr68rGaaNIeJoJOpLQ2WTqNI"} 86 | ` 87 | if rr.Body.String() != expected { 88 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 89 | rr.Body.String(), expected) 90 | } 91 | } 92 | 93 | func TestHandleApiNewUnauthorized(t *testing.T) { 94 | monkey.Patch(time.Now, func() time.Time { 95 | return mockNow 96 | }) 97 | defer monkey.Unpatch(time.Now) 98 | secretStore := make(store.SecretStore) 99 | auth := tokendb.MakeTokenDB([]byte(`[{ 100 | "token": "footoken", 101 | "email": "test@example.org" 102 | }]`)) 103 | urlbase := "http://localhost:9154" 104 | postdata := bytes.NewReader([]byte(`{ 105 | "auth_token": "wrongtoken", 106 | "secret": "sekrit", 107 | "max_clicks": 3 108 | }`)) 109 | req, _ := http.NewRequest("POST", urlbase+ApiNew, postdata) 110 | rr := httptest.NewRecorder() 111 | handler := HandleApiNew(secretStore, urlbase, &auth) 112 | handler.ServeHTTP(rr, req) 113 | if status := rr.Code; status != http.StatusUnauthorized { 114 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusUnauthorized) 115 | } 116 | expected := `{"error":"unauthorized"} 117 | ` 118 | if rr.Body.String() != expected { 119 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 120 | rr.Body.String(), expected) 121 | } 122 | } 123 | 124 | type mockFailingResponseWriter struct { 125 | statusCode int 126 | headers http.Header 127 | } 128 | 129 | func (m *mockFailingResponseWriter) WriteHeader(_ int) { 130 | return 131 | } 132 | 133 | func (m *mockFailingResponseWriter) Header() http.Header { 134 | if m.headers == nil { 135 | m.headers = make(http.Header) 136 | } 137 | return m.headers 138 | } 139 | 140 | func (m *mockFailingResponseWriter) Write(_ []byte) (int, error) { 141 | return 0, fmt.Errorf("simulated write error") 142 | } 143 | 144 | func TestHandleApiNewMalformed(t *testing.T) { 145 | monkey.Patch(time.Now, func() time.Time { 146 | return mockNow 147 | }) 148 | defer monkey.Unpatch(time.Now) 149 | secretStore := make(store.SecretStore) 150 | auth := tokendb.MakeTokenDB([]byte(`[{ 151 | "token": "footoken", 152 | "email": "test@example.org" 153 | }]`)) 154 | urlbase := "http://localhost:9154" 155 | postdata := bytes.NewReader([]byte(`{ 156 | "auth_token": "wrongtoken", 157 | "secret": 24, 158 | "max_clicks": "baz" 159 | }`)) 160 | req, _ := http.NewRequest("POST", urlbase+ApiNew, postdata) 161 | rr := httptest.NewRecorder() 162 | handler := HandleApiNew(secretStore, urlbase, &auth) 163 | handler.ServeHTTP(rr, req) 164 | if status := rr.Code; status != http.StatusUnprocessableEntity { 165 | t.Errorf("handler returned wrong status code: got %v, wanted %v", status, http.StatusUnprocessableEntity) 166 | } 167 | expected := `{"error":"json: cannot unmarshal number into Go struct field StoreEntry.secret of type string"} 168 | ` 169 | if rr.Body.String() != expected { 170 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", 171 | rr.Body.String(), expected) 172 | } 173 | } 174 | 175 | func TestJsonRespond(t *testing.T) { 176 | t.Run("happy case", func(t *testing.T) { 177 | rr := httptest.NewRecorder() 178 | type testContent struct { 179 | SomeValue string `json:"somevalue"` 180 | } 181 | 182 | jsonRespond(rr, http.StatusOK, testContent{"foobar"}) 183 | 184 | expectedContentType := "application/json; charset=UTF-8" 185 | if rr.Header().Get("Content-Type") != expectedContentType { 186 | t.Errorf("handler returned wrong content type: got %v, wanted %v", rr.Header().Get("Content-Type"), expectedContentType) 187 | } 188 | if rr.Code != http.StatusOK { 189 | t.Errorf("handler returned wrong status code: got %v, wanted %v", rr.Code, http.StatusOK) 190 | } 191 | expectedBody := `{"somevalue":"foobar"} 192 | ` 193 | if rr.Body.String() != expectedBody { 194 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", rr.Body.String(), expectedBody) 195 | } 196 | }) 197 | 198 | t.Run("unhappy case: json encoding error", func(t *testing.T) { 199 | rr := httptest.NewRecorder() 200 | var invalidData chan int 201 | 202 | jsonRespond(rr, http.StatusOK, invalidData) 203 | 204 | expectedBody := `{"error":"internal error"}` 205 | if rr.Body.String() != expectedBody { 206 | t.Errorf("handler returned unexpected body: got\n%v want\n%v", rr.Body.String(), expectedBody) 207 | } 208 | }) 209 | 210 | t.Run("unhappy case: can not write", func(t *testing.T) { 211 | var buf bytes.Buffer 212 | log.SetOutput(&buf) 213 | defer func() { 214 | log.SetOutput(os.Stderr) 215 | }() 216 | rr := &mockFailingResponseWriter{statusCode: http.StatusOK, headers: make(http.Header)} 217 | 218 | jsonRespond(rr, http.StatusOK, "foo") 219 | 220 | expectedError := `error writing response: simulated write error 221 | ` 222 | if !strings.HasSuffix(buf.String(), expectedError) { 223 | t.Errorf("handler did not log error: got\n%v wanted suffix\n%v", buf.String(), expectedError) 224 | } 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /httpio/routes.go: -------------------------------------------------------------------------------- 1 | package httpio 2 | 3 | var ( 4 | Get = "/g" 5 | ApiGet = "/api/v1/get/" 6 | ApiNew = "/api/v1/new" 7 | Create = "/create" 8 | Info = "/i" 9 | ClientShell = "/gjfy-post" 10 | Fav = "/favicon.ico" 11 | LogoSmall = "/gjfy-logo-small.png" 12 | Css = "/custom.css" 13 | Logo = "/logo.png" 14 | ) 15 | -------------------------------------------------------------------------------- /httpio/static_handlers.go: -------------------------------------------------------------------------------- 1 | package httpio 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/sstark/gjfy/fileio" 9 | "github.com/sstark/gjfy/misc" 10 | ) 11 | 12 | func HandleStaticFav() http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | w.Header().Set("Content-Type", "image/x-icon") 15 | w.WriteHeader(http.StatusOK) 16 | w.Write(fileio.Favicon) 17 | }) 18 | } 19 | 20 | func HandleStaticLogoSmall() http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | w.Header().Set("Content-Type", "image/png") 23 | w.WriteHeader(http.StatusOK) 24 | w.Write(fileio.GjfyLogoSmall) 25 | }) 26 | } 27 | 28 | func HandleStaticCss(cssp *[]byte, updatedp *time.Time) http.Handler { 29 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | css := *cssp 31 | updated := *updatedp 32 | http.ServeContent(w, r, fileio.CssFileName, updated, bytes.NewReader(css)) 33 | }) 34 | } 35 | 36 | func HandleStaticLogo(logop *[]byte, updatedp *time.Time) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | logo := *logop 39 | updated := *updatedp 40 | http.ServeContent(w, r, fileio.LogoFileName, updated, bytes.NewReader(logo)) 41 | }) 42 | } 43 | 44 | func HandleStaticClientShellScript(urlbase string) http.Handler { 45 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | w.Header().Set("Content-Type", "application/x-sh") 47 | w.WriteHeader(http.StatusOK) 48 | misc.ClientShellScript(w, urlbase+ApiNew) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /httpio/view_handlers.go: -------------------------------------------------------------------------------- 1 | package httpio 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/sstark/gjfy/fileio" 10 | "github.com/sstark/gjfy/store" 11 | ) 12 | 13 | type viewInfoEntry struct { 14 | store.StoreEntryInfo 15 | UserMessageView *string 16 | } 17 | 18 | var htmlTemplates *template.Template 19 | 20 | func init() { 21 | htmlTemplates = template.Must(template.ParseFS(fileio.HtmlTemplates, "*.tmpl")) 22 | } 23 | 24 | func HandleIndex(fAllowAnonymous bool) http.Handler { 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | w.WriteHeader(http.StatusOK) 27 | type Data struct { 28 | AllowAnonymous bool 29 | } 30 | htmlTemplates.ExecuteTemplate(w, "index", &Data{AllowAnonymous: fAllowAnonymous}) 31 | }) 32 | } 33 | 34 | func HandleGet(memstore store.SecretStore, urlbase string, fNotify bool, userMessageView *string) http.Handler { 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | id := r.URL.Query().Get("id") 37 | if entry, ok := memstore.GetEntryInfo(id, urlbase, Get, ApiGet); !ok { 38 | w.WriteHeader(http.StatusNotFound) 39 | log.Printf("entry not found: %s", id) 40 | htmlTemplates.ExecuteTemplate(w, "error", nil) 41 | } else { 42 | memstore.Click(id, r, fNotify) 43 | w.WriteHeader(http.StatusOK) 44 | viewEntry := viewInfoEntry{entry, userMessageView} 45 | htmlTemplates.ExecuteTemplate(w, "view", viewEntry) 46 | } 47 | }) 48 | } 49 | 50 | func HandleInfo(memstore store.SecretStore, urlbase string) http.Handler { 51 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 | id := r.URL.Query().Get("id") 53 | if entry, ok := memstore.GetEntryInfo(id, urlbase, Get, ApiGet); !ok { 54 | w.WriteHeader(http.StatusNotFound) 55 | htmlTemplates.ExecuteTemplate(w, "error", nil) 56 | } else { 57 | w.WriteHeader(http.StatusOK) 58 | htmlTemplates.ExecuteTemplate(w, "info", entry) 59 | } 60 | }) 61 | } 62 | 63 | func HandleCreate(memstore store.SecretStore, urlbase string) http.Handler { 64 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 | err := r.ParseForm() 66 | if err != nil { 67 | http.Error(w, err.Error(), http.StatusBadRequest) 68 | return 69 | } 70 | entry := memstore.NewEntry(r.Form.Get("secret"), 1, 7, "anonymous", "") 71 | w.Write(fmt.Appendf([]byte{}, "%s%s?id=%s", urlbase, Get, entry)) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /init/README.md: -------------------------------------------------------------------------------- 1 | Examples for integrating gjfy with various startup systems 2 | ========================================================== 3 | 4 | Examples provided for: systemd 5 | 6 | Example configuration assumes that you have: 7 | 8 | - a system user account with the name "gjfy" and primary group "nogroup" 9 | (although you could of course choose totally different names 10 | for those) 11 | - a directory /usr/local/gjfy that is readable by the gjfy user 12 | - all required gjfy files installed into that directory 13 | 14 | Installation for systemd 15 | ------------------------ 16 | 17 | Copy `init/systemd/gjfy.service` to `/etc/systemd/system`. Change as necessary. 18 | Run `systemctl enable gjfy` and `systemctl start gjfy`. To watch the logs run `journalctl -u gjfy` 19 | -------------------------------------------------------------------------------- /init/systemd/gjfy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=GJFY one-time link server 3 | 4 | [Service] 5 | Type=simple 6 | User=gjfy 7 | Group=nogroup 8 | WorkingDirectory=/usr/local/gjfy 9 | ExecStart=/usr/local/gjfy/gjfy server --listen :9154 --urlbase http://gjfy.example.org:9154 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /logo_s.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sstark/gjfy/681717b40cbc09655654f0ec2d5e942a8d38a606/logo_s.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/sstark/gjfy/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /misc/client-sh.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "io" 5 | "text/template" 6 | ) 7 | 8 | type ClientVars struct { 9 | DefaultPostURL string 10 | } 11 | 12 | const ( 13 | shellClient = `#!/bin/bash 14 | 15 | POSTURL="${GJFY_POSTURL:-{{.DefaultPostURL}}}" 16 | 17 | which jq >/dev/null 2>&1 || { 18 | echo "jq utility not found" >&2 19 | exit 1 20 | } 21 | 22 | if [[ $# -lt 2 ]] 23 | then 24 | echo "usage: $0 " >&2 25 | exit 2 26 | fi 27 | 28 | if [[ -z "$3" ]] 29 | then 30 | postdata="{\"auth_token\":\"$1\",\"secret\":\"$2\"}" 31 | else 32 | postdata="{\"auth_token\":\"$1\",\"secret\":\"$2\",\"max_clicks\":$3}" 33 | fi 34 | curl -s -X POST -d "$postdata" "$POSTURL" | jq -r '.url,.api_url,.error | select (.!=null)' 35 | ` 36 | ) 37 | 38 | func ClientShellScript(out io.Writer, url string) error { 39 | cv := ClientVars{url} 40 | tmpl, err := template.New("shellClient").Parse(shellClient) 41 | if err != nil { 42 | return err 43 | } 44 | err = tmpl.Execute(out, cv) 45 | if err != nil { 46 | return err 47 | } 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /misc/headers.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import "net/http" 4 | 5 | func GetRealIP(r *http.Request) string { 6 | xFF := r.Header.Get("X-Forwarded-For") 7 | xRI := r.Header.Get("X-Real-IP") 8 | if xFF != "" { 9 | return xFF 10 | } else if xRI != "" { 11 | return xRI 12 | } 13 | return "none" 14 | } 15 | -------------------------------------------------------------------------------- /misc/headers_test.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | type GetRealIPTestCase struct { 9 | request http.Request 10 | headers map[string]string 11 | expected string 12 | } 13 | 14 | func makeFauxRequest(url string) *http.Request { 15 | req, _ := http.NewRequest(http.MethodGet, url, nil) 16 | return req 17 | } 18 | 19 | func TestGetRealIP(t *testing.T) { 20 | tests := map[string]GetRealIPTestCase{ 21 | "no proxy headers": { 22 | *makeFauxRequest("/api/v1/foo"), 23 | make(map[string]string), 24 | "none", 25 | }, 26 | "forwarded-for": { 27 | *makeFauxRequest("/api/v1/fie"), 28 | map[string]string{ 29 | "X-Forwarded-For": "123.123.123.123", 30 | }, 31 | "123.123.123.123", 32 | }, 33 | "real-ip": { 34 | *makeFauxRequest("/api/v1/bar"), 35 | map[string]string{ 36 | "X-Real-IP": "222.222.111.111", 37 | }, 38 | "222.222.111.111", 39 | }, 40 | "both": { 41 | *makeFauxRequest("/api/v1/baz"), 42 | map[string]string{ 43 | "X-Real-IP": "101.101.202.202", 44 | "X-Forwarded-For": "144.144.133.133", 45 | }, 46 | "144.144.133.133", 47 | }, 48 | } 49 | for label, test := range tests { 50 | t.Run(label, func(t *testing.T) { 51 | for header, val := range test.headers { 52 | test.request.Header.Add(header, val) 53 | } 54 | got := GetRealIP(&test.request) 55 | if got != test.expected { 56 | t.Errorf("Got a different header value (%s) than expected (%s)", got, test.expected) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /misc/mail.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os/exec" 7 | ) 8 | 9 | func NotifyMail(to, msg string) { 10 | go SendMail(to, "GJFY notice", msg) 11 | } 12 | 13 | func SendMail(to, subject, msg string) { 14 | sendmail := exec.Command("mail", "-s", subject, to) 15 | stdin, err := sendmail.StdinPipe() 16 | if err != nil { 17 | log.Println(err) 18 | return 19 | } 20 | stdout, err := sendmail.StdoutPipe() 21 | if err != nil { 22 | log.Println(err) 23 | return 24 | } 25 | sendmail.Start() 26 | stdin.Write([]byte(msg)) 27 | stdin.Write([]byte("\n")) 28 | stdin.Close() 29 | io.ReadAll(stdout) 30 | sendmail.Wait() 31 | log.Printf("sending notification to %s done.\n", to) 32 | } 33 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/sstark/gjfy/misc" 12 | ) 13 | 14 | const ( 15 | hiddenString = "#HIDDEN#" 16 | defaultValidity = 7 // days 17 | defaultMaxClicks = 1 18 | ) 19 | 20 | var ( 21 | expFactor = realExpFactor 22 | ) 23 | 24 | // In-memory representation of a secret. 25 | type StoreEntry struct { 26 | Secret string `json:"secret"` 27 | MaxClicks int `json:"max_clicks"` 28 | Clicks int `json:"clicks"` 29 | DateAdded time.Time `json:"date_added"` 30 | ValidFor int `json:"valid_for"` 31 | AuthToken string `json:"auth_token"` 32 | } 33 | 34 | // Secret augmented with computed fields. 35 | type StoreEntryInfo struct { 36 | StoreEntry 37 | Id string `json:"id"` 38 | PathQuery string `json:"path_query"` 39 | Url string `json:"url"` 40 | ApiUrl string `json:"api_url"` 41 | } 42 | 43 | type SecretStore map[string]StoreEntry 44 | 45 | // hashStruct returns a hash from an arbitrary structure, usable in a URL. 46 | func hashStruct(data any) (hash string) { 47 | hashBytes := sha256.Sum256(fmt.Appendf([]byte{}, "%v", data)) 48 | hash = base64.RawURLEncoding.EncodeToString(hashBytes[:]) 49 | return 50 | } 51 | 52 | // AddEntry adds a secret to the store. 53 | func (st SecretStore) AddEntry(e StoreEntry, id string) string { 54 | e.DateAdded = time.Now() 55 | if id == "" { 56 | id = hashStruct(e) 57 | } 58 | if e.ValidFor == 0 { 59 | e.ValidFor = defaultValidity 60 | } 61 | if e.MaxClicks == 0 { 62 | e.MaxClicks = defaultMaxClicks 63 | } 64 | st[id] = e 65 | return id 66 | } 67 | 68 | // NewEntry adds a new secret to the store. Set id to "" 69 | // to have it auto-generated by hashing the entry. 70 | func (st SecretStore) NewEntry(secret string, maxclicks int, validfor int, at string, id string) string { 71 | return st.AddEntry(StoreEntry{secret, maxclicks, 0, time.Time{}, validfor, at}, id) 72 | } 73 | 74 | // GetEntry retrieves a secret from the store. 75 | func (st SecretStore) GetEntry(id string) (se StoreEntry, ok bool) { 76 | se, ok = st[id] 77 | return 78 | } 79 | 80 | // GetEntryInfo wraps GetEntry and adds some computed fields. 81 | func (st SecretStore) GetEntryInfo(id, urlbase, urlget, urlapiget string) (si StoreEntryInfo, ok bool) { 82 | entry, ok := st.GetEntry(id) 83 | pathQuery := urlget + "?id=" + id 84 | url := urlbase + pathQuery 85 | apiurl := urlbase + urlapiget + id 86 | return StoreEntryInfo{entry, id, pathQuery, url, apiurl}, ok 87 | } 88 | 89 | // GetEntryInfo wraps GetEntry and adds some computed fields. In addition it 90 | // hides the "secret" value. 91 | func (st SecretStore) GetEntryInfoHidden(id, urlbase, urlget, urlapiget string) (si StoreEntryInfo, ok bool) { 92 | si, ok = st.GetEntryInfo(id, urlbase, urlget, urlapiget) 93 | si.Secret = hiddenString 94 | return 95 | } 96 | 97 | // Click increases the click counter for an entry and sends a notification 98 | func (st SecretStore) Click(id string, r *http.Request, fNotify bool) { 99 | var msg string 100 | entry, ok := st.GetEntry(id) 101 | if ok { 102 | // in any case increase number of clicks in our temporary entry 103 | entry.Clicks += 1 104 | if entry.Clicks < entry.MaxClicks { 105 | // max clicks not yet reached, save our modified entry to the store 106 | st[id] = entry 107 | } else { 108 | // max clicks reached, delete entry from store 109 | delete(st, id) 110 | } 111 | msg = fmt.Sprintf(` 112 | Id: %s 113 | Clicked: %d time(s) 114 | Clicks left: %d 115 | Request: %s (%s) %s %s %s 116 | User-Agent: %s 117 | `, 118 | id, entry.Clicks, entry.MaxClicks-entry.Clicks, 119 | r.RemoteAddr, misc.GetRealIP(r), r.Method, r.URL.Path, r.Proto, 120 | r.Header.Get("User-Agent")) 121 | if fNotify == true { 122 | misc.NotifyMail(entry.AuthToken, msg) 123 | } 124 | } 125 | return 126 | } 127 | 128 | // realExpFactor will scale the given int value to a certain amount of time 129 | func realExpFactor(v int) time.Duration { 130 | return time.Hour * 24 * time.Duration(v) 131 | } 132 | 133 | // Expiry checks for expired entries at regular intervals 134 | func (st SecretStore) Expiry(interval time.Duration) { 135 | tck := time.NewTicker(interval) 136 | log.Printf("checking for expiration every %s\n", interval) 137 | for { 138 | now := time.Now() 139 | for id, e := range st { 140 | expDate := e.DateAdded 141 | expDate = expDate.Add(expFactor(e.ValidFor)) 142 | if now.After(expDate) { 143 | log.Printf("%s expired\n", id) 144 | delete(st, id) 145 | } 146 | } 147 | <-tck.C 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "net/http/httptest" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "bou.ke/monkey" 10 | ) 11 | 12 | var mockNow = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) 13 | 14 | func TestHashStruct(t *testing.T) { 15 | var in = StoreEntry{"secret1", 5, 0, mockNow, 3, "authtoken"} 16 | var wanted = "0Y3Mkcz36xM0hwrSnVw3PMebEMfa27Oi1mmuaELD4-Q" 17 | got := hashStruct(in) 18 | if got != wanted { 19 | t.Errorf("got %v, wanted %v", got, wanted) 20 | } 21 | } 22 | 23 | type StoreEntryInput struct { 24 | secret string 25 | maxClicks int 26 | validFor int 27 | authToken string 28 | id string 29 | } 30 | 31 | type StoreEntryOutput struct { 32 | StoreEntry 33 | id string 34 | } 35 | 36 | type StoreEntryTestPair struct { 37 | in StoreEntryInput 38 | out StoreEntryOutput 39 | } 40 | 41 | var StoreEntryTestPairs = []StoreEntryTestPair{ 42 | { 43 | StoreEntryInput{"secret1", 5, 3, "authtoken", "id1"}, 44 | StoreEntryOutput{StoreEntry{"secret1", 5, 0, mockNow, 3, "authtoken"}, "id1"}, 45 | }, 46 | { 47 | StoreEntryInput{"secret2", 2, 3, "authtoken", ""}, 48 | StoreEntryOutput{StoreEntry{"secret2", 2, 0, mockNow, 3, "authtoken"}, "iLbLBYFzULLUfB84p8VHldWd4VnHg0mZq_5S45p0lEk"}, 49 | }, 50 | } 51 | 52 | func TestStore_NewEntry(t *testing.T) { 53 | monkey.Patch(time.Now, func() time.Time { 54 | return mockNow 55 | }) 56 | defer monkey.Unpatch(time.Now) 57 | store := make(SecretStore) 58 | for _, p := range StoreEntryTestPairs { 59 | outId := store.NewEntry(p.in.secret, p.in.maxClicks, p.in.validFor, p.in.authToken, p.in.id) 60 | if outId != p.out.id { 61 | t.Errorf("got %v, wanted %v", outId, p.out.id) 62 | } 63 | outEntry, ok := store.GetEntry(outId) 64 | if !ok { 65 | t.Errorf("new entry not found under %v", outId) 66 | } 67 | if !reflect.DeepEqual(p.out.StoreEntry, outEntry) { 68 | t.Errorf("got %v, wanted %v", p.out.StoreEntry, outEntry) 69 | } 70 | } 71 | } 72 | 73 | func TestStore_GetEntryInfo(t *testing.T) { 74 | store := make(SecretStore) 75 | store.NewEntry("secret", 1, 1, "auth", "testid") 76 | out, ok := store.GetEntryInfoHidden("testid", "http://localhost:", "/g", "/api/v1/get/") 77 | if !ok { 78 | t.Errorf("new entry not found under %v", "testid") 79 | } 80 | wanted := "http://localhost:/api/v1/get/testid" 81 | if out.ApiUrl != wanted { 82 | t.Errorf("got %v, wanted %v", out.ApiUrl, wanted) 83 | } 84 | wanted = "http://localhost:/g?id=testid" 85 | if out.Url != wanted { 86 | t.Errorf("got %v, wanted %v", out.Url, wanted) 87 | } 88 | wanted = hiddenString 89 | if out.Secret != wanted { 90 | t.Errorf("got %v, wanted %v", out.Secret, wanted) 91 | } 92 | } 93 | 94 | func TestStore_Click(t *testing.T) { 95 | clicks := 2 96 | store := make(SecretStore) 97 | store.NewEntry("secret", clicks, 1, "auth", "testid") 98 | _, ok := store.GetEntry("testid") 99 | if !ok { 100 | t.Errorf("new entry not found under %v", "testid") 101 | } 102 | req := httptest.NewRequest("GET", "/testid", nil) 103 | for i := 0; i < clicks; i++ { 104 | store.Click("testid", req, false) 105 | } 106 | _, ok = store.GetEntry("testid") 107 | if ok { 108 | t.Errorf("new entry found under %v, but it should not be there", "testid") 109 | } 110 | } 111 | 112 | func TestStore_Expiry(t *testing.T) { 113 | store := make(SecretStore) 114 | store.NewEntry("secret", 1, 150, "auth", "testid") 115 | _, ok := store.GetEntry("testid") 116 | if !ok { 117 | t.Errorf("new entry not found under %v", "testid") 118 | } 119 | expFactor = func(v int) time.Duration { 120 | return time.Millisecond * time.Duration(v) 121 | } 122 | go store.Expiry(time.Millisecond * 200) 123 | time.Sleep(time.Millisecond * 300) 124 | _, ok = store.GetEntry("testid") 125 | if ok { 126 | t.Errorf("new entry found under %v, but it should be expired", "testid") 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tag-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | v="$1" 4 | if [[ -z $v ]] 5 | then 6 | print "usage: $0 " 7 | exit 1 8 | fi 9 | 10 | if [[ $v == v* ]] 11 | then 12 | print "do not add the v prefix, tag should look like \"1.5\"" 13 | exit 1 14 | fi 15 | 16 | grep -q "version = \"$v\"" version.go 17 | if [[ $? != 0 ]] 18 | then 19 | print "fix version.go first" 20 | exit 1 21 | fi 22 | echo git tag -s v$v -m \"tag gjfy v$v\" 23 | -------------------------------------------------------------------------------- /tokendb/auth.go: -------------------------------------------------------------------------------- 1 | package tokendb 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "github.com/sstark/gjfy/store" 8 | ) 9 | 10 | const ( 11 | AuthFileName = "auth.db" 12 | ) 13 | 14 | type AuthToken struct { 15 | Token string `json:"token"` 16 | Email string `json:"email"` 17 | } 18 | 19 | type TokenDB []AuthToken 20 | 21 | func MakeTokenDB(b []byte) TokenDB { 22 | var tokens TokenDB 23 | err := json.Unmarshal(b, &tokens) 24 | if err != nil { 25 | log.Println("error reading auth token db:", err) 26 | } 27 | for i, entry := range tokens { 28 | if entry.Token == "" { 29 | log.Printf("token field empty or missing in entry #%d", i) 30 | return nil 31 | } 32 | if entry.Email == "" { 33 | log.Printf("email field empty or missing in entry #%d", i) 34 | return nil 35 | } 36 | } 37 | log.Printf("found %d auth tokens\n", len(tokens)) 38 | return tokens 39 | } 40 | 41 | func (db TokenDB) findToken(token string) (email string) { 42 | for _, i := range db { 43 | if i.Token == token { 44 | email = i.Email 45 | return 46 | } 47 | } 48 | return 49 | } 50 | 51 | // IsAuthorized tries to find the auth token given in entry. 52 | // It will then change the entry parameter by replacing the auth 53 | // token with the associated email address. This is to have the 54 | // auth token not end up in the secret database. 55 | func (db TokenDB) IsAuthorized(entry *store.StoreEntry) bool { 56 | email := db.findToken(entry.AuthToken) 57 | if email == "" { 58 | return false 59 | } 60 | entry.AuthToken = email 61 | return true 62 | } 63 | -------------------------------------------------------------------------------- /tokendb/auth_test.go: -------------------------------------------------------------------------------- 1 | package tokendb 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "testing" 7 | 8 | "github.com/sstark/gjfy/store" 9 | ) 10 | 11 | type tdbTestPair struct { 12 | in []byte 13 | out bool 14 | } 15 | 16 | var tdbTestPairs = []tdbTestPair{ 17 | { 18 | in: []byte("bla"), 19 | out: false, 20 | }, 21 | { 22 | in: []byte(`[{ 23 | "token": "test", 24 | "email": "test@example.org" 25 | }, 26 | { 27 | "token": "test2", 28 | "email": "other@example.org" 29 | }]`), 30 | out: true, 31 | }, 32 | } 33 | 34 | func TestAuth_makeTokenDB(t *testing.T) { 35 | log.SetOutput(io.Discard) 36 | for _, pair := range tdbTestPairs { 37 | tdb := MakeTokenDB(pair.in) 38 | if (tdb != nil) != pair.out { 39 | t.Errorf("%v should be %v", tdb, pair.out) 40 | } 41 | } 42 | } 43 | 44 | type tdbFindTokenTest struct { 45 | db []byte 46 | in string 47 | out string 48 | } 49 | 50 | func TestAuthFindToken(t *testing.T) { 51 | tdbFindTokenTests := map[string]tdbFindTokenTest{ 52 | "look for non-existing entry": { 53 | db: []byte("bla"), 54 | in: "foo@example.com", 55 | out: "", 56 | }, 57 | "look for existing entry": { 58 | db: []byte(`[{ 59 | "token": "test", 60 | "email": "test@example.org" 61 | }, 62 | { 63 | "token": "test2", 64 | "email": "other@example.org" 65 | }]`), 66 | in: "test2", 67 | out: "other@example.org", 68 | }, 69 | } 70 | for label, test := range tdbFindTokenTests { 71 | t.Run(label, func(t *testing.T) { 72 | tdb := MakeTokenDB(test.db) 73 | out := tdb.findToken(test.in) 74 | if out != test.out { 75 | t.Errorf("unexpected output: expected %v, got %v", test.out, out) 76 | } 77 | }) 78 | } 79 | } 80 | 81 | type tdbIsAuthorizedTest struct { 82 | db []byte 83 | in string 84 | found bool 85 | email string 86 | out bool 87 | } 88 | 89 | func TestIsAuthorized(t *testing.T) { 90 | tdbIsAuthorizedTests := map[string]tdbIsAuthorizedTest{ 91 | "look for non-existing entry": { 92 | db: []byte("bla"), 93 | in: "foobar", 94 | found: false, 95 | email: "", 96 | out: false, 97 | }, 98 | "look for existing entry": { 99 | db: []byte(`[{ 100 | "token": "test", 101 | "email": "test@example.org" 102 | }, 103 | { 104 | "token": "test2", 105 | "email": "other@example.org" 106 | }]`), 107 | in: "id", 108 | found: true, 109 | email: "other@example.org", 110 | out: true, 111 | }, 112 | } 113 | store := make(store.SecretStore) 114 | store.NewEntry("secret", 1, 1, "test2", "id") 115 | for label, test := range tdbIsAuthorizedTests { 116 | t.Run(label, func(t *testing.T) { 117 | entry, ok := store.GetEntry(test.in) 118 | if ok != test.found { 119 | t.Errorf("when finding store entry: expected %v, got %v", test.found, ok) 120 | } 121 | tdb := MakeTokenDB(test.db) 122 | out := tdb.IsAuthorized(&entry) 123 | if out != test.out { 124 | t.Errorf("unexpected output: expected %v, got %v", test.out, out) 125 | } 126 | // A side-effect of isAuthorized is to change the token into the email address, check for that 127 | if ok { 128 | if entry.AuthToken != test.email { 129 | t.Errorf("entry AuthToken field was not changed to expected value: expected %v, got %v", test.email, entry.AuthToken) 130 | } 131 | } 132 | }) 133 | } 134 | } 135 | --------------------------------------------------------------------------------