~mro/pin4sha.cgi

8c0db45a0cf911e82ba9908a08677a23e121650c — Marcus Rohrmoser 4 years ago 5b7e87a + 01ff41a
Merge branch 'pinboard.in/go'
32 files changed, 1305 insertions(+), 1541 deletions(-)

M .gitignore
M README.md
A bindata.go
R pinboard.sh => build.sh
M doap.rdf
A model.go
A model_test.go
A openapi.yaml
D patches/sebsauvage/Shaarli/archive/master/001.patch
M pinboard.go
M pinboard_test.go
A post.msc
A post.png
D scripts/assert.sh
D scripts/categories.rng
D scripts/download.sh
D scripts/form2post.rb
D scripts/run-tests.sh
D scripts/tagcloud-html2atom.xslt
D tests/test-atom-empty.sh
D tests/test-delete-all-ok.sh.xslt
D tests/test-delete-ok.sh.xslt
D tests/test-index.sh
D tests/test-login-ok.sh
D tests/test-pinboard-info.sh
D tests/test-post.sh
D tests/test-tagcloud.sh
D tests/test-title.sh
D tests/test_delete-all-ok.sh
D tests/test_delete-ok.sh
D tests/test_login-fail.sh
D tmp/.gitkeep
M .gitignore => .gitignore +1 -1
@@ 3,4 3,4 @@ scripts/response.rnc
scripts/categories.rnc
source.tar.gz
tmp/
pinboard-linux-amd64-*.gz
\ No newline at end of file
pinboard4shaarli-linux-amd64-*.gz

M README.md => README.md +44 -2
@@ 1,5 1,47 @@
# Shaarli-API-test
# pinboard4shaarli.cgi

The natural API for shaarli.

[![Build Status](https://travis-ci.org/mro/Shaarli-API-test.svg?branch=master)](https://travis-ci.org/mro/Shaarli-API-test)

See https://github.com/mro/ShaarliOS/issues/1
## Why?

The wish to have an API goes back to the [early days](https://sebsauvage.net/wiki/doku.php?id=php:shaarli:ideas).

And because shaarli started as a personal, minimal delicious clone, using a minimal subset of just
the delicious API, seems natural to me. [pinboard](https://pinboard.in/api/) prooves the API
is not only seasoned and mature, but also still up the job today.

Also I need a drop-in API compatibility layer for a wide range of shaarli versions out there being
fed to by [ShaarliOS](https://code.mro.name/mro/ShaarliOS/).

## How?

You find a single, statically linked, zero-dependencies ([Go](https://golang.org/)) binary which is both a

1. cgi to drop into your shaarli php webapplication next to index.php – as the API endpoint,
2. commandline client to any shaarli out there, mostly for debugging purposes.

![post flow](post.png)

## Compatibility

All shaarlis from the old ages until at least spring 2019 (v0.10.3).

All systems [Go](https://golang.org/) can produce binaries for.

Just the delicious API calls in [openapi.yaml](openapi.yaml)

The API server code (delicious implementation) is the same as in [ShaarliGo](https://code.mro.name/mro/ShaarliGo).

## Design Goals

| Quality         | very good | good | normal | irrelevant |
|-----------------|:---------:|:----:|:------:|:----------:|
| Functionality   |           |      |    ×   |            |
| Reliability     |     ×     |      |        |            |
| Usability       |           |   ×  |        |            |
| Efficiency      |           |      |    ×   |            |
| Changeability   |           |   ×  |        |            |
| Portability     |     ×     |      |        |            |


A bindata.go => bindata.go +258 -0
@@ 0,0 1,258 @@
// Code generated by go-bindata.
// sources:
// doap.rdf
// openapi.yaml
// DO NOT EDIT!

package main

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
	"time"
)

func bindataRead(data []byte, name string) ([]byte, error) {
	gz, err := gzip.NewReader(bytes.NewBuffer(data))
	if err != nil {
		return nil, fmt.Errorf("Read %q: %v", name, err)
	}

	var buf bytes.Buffer
	_, err = io.Copy(&buf, gz)
	clErr := gz.Close()

	if err != nil {
		return nil, fmt.Errorf("Read %q: %v", name, err)
	}
	if clErr != nil {
		return nil, err
	}

	return buf.Bytes(), nil
}

type asset struct {
	bytes []byte
	info  os.FileInfo
}

type bindataFileInfo struct {
	name    string
	size    int64
	mode    os.FileMode
	modTime time.Time
}

func (fi bindataFileInfo) Name() string {
	return fi.name
}
func (fi bindataFileInfo) Size() int64 {
	return fi.size
}
func (fi bindataFileInfo) Mode() os.FileMode {
	return fi.mode
}
func (fi bindataFileInfo) ModTime() time.Time {
	return fi.modTime
}
func (fi bindataFileInfo) IsDir() bool {
	return false
}
func (fi bindataFileInfo) Sys() interface{} {
	return nil
}

var _doapRdf = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xa4\x94\x5f\x4e\xdc\x40\x0c\xc6\xdf\xf7\x14\x56\x78\xe0\x29\x19\xa0\x7d\x28\xab\xec\x22\xd4\xd2\x0a\xa9\xad\x10\x3d\x81\x37\x71\xb2\x2e\x33\xe3\xc8\x9e\xec\x2e\x47\xe2\x1c\x5c\xac\x0a\x59\x52\x15\x50\x85\xe0\x6d\x64\xcf\xf7\xf3\x67\xcf\x9f\xf2\x6c\x17\x3c\x6c\x48\x8d\x25\x2e\xb2\xe3\xe2\x28\x03\x8a\x95\xd4\x1c\xdb\x45\xd6\xa7\x26\xff\x94\x9d\x2d\x67\xa5\xd6\xcd\xfc\xfa\xcb\x57\xd8\x05\x1f\x6d\xae\x75\xb3\xc8\xd6\x29\x75\x73\xe7\xb6\xdb\x6d\xb1\xfd\x50\x88\xb6\xee\xf8\xf4\xf4\xd4\x1d\x9d\xb8\x93\x93\x5c\xeb\x26\xb7\xdb\x98\x70\x97\x47\x3b\xc8\x66\x00\xa3\x74\x92\xf5\x46\x4d\xef\x39\x56\x45\x25\xc1\x45\x73\xb5\x60\x77\x90\x2d\x67\x00\xe5\x95\xca\x6f\xaa\xd2\xb0\x06\x28\x57\x7d\x9b\xd7\x98\x70\x85\x46\x30\xf8\x50\x32\xe9\xb5\xa2\x91\x65\x73\xe7\x2a\xa9\xa9\x08\x2a\x45\xc4\x40\x2e\xa8\xb8\x8e\xe3\x4a\x50\xeb\x8f\xb6\x46\x54\xcf\x8e\xcd\x7a\xb2\xcc\xed\xa1\x6b\x09\xd4\x61\xfb\x0e\xe0\x84\xe2\xd0\x79\x0a\x14\x93\x3d\x81\x6d\x8e\x9d\x74\x14\xb1\xe3\xe2\x16\x83\x9f\x04\x9e\x2b\x8a\xef\xe9\xc5\xb4\x72\x01\x2d\x91\xba\xef\x97\x9f\x2f\x7e\xfe\xba\x98\xd8\x01\x39\x26\xe4\x48\xfa\x5a\xfc\x24\x1d\x02\xcb\xa7\xb5\x8a\xaa\xe5\xd2\x3d\xa4\xc6\x5d\x9d\x4a\xab\x18\x02\xc7\x36\xf7\x18\xdb\x1e\x5b\x5a\xb6\x32\x2c\x4b\xf7\x62\x72\xd4\x29\x75\x62\x9c\x44\x6f\xc7\x00\x40\xf9\x8d\xd3\xf5\xb3\xe8\x70\xe0\x2a\xdb\x77\x8c\xe7\xb1\xa1\x71\xd6\x52\x61\x62\x89\x6f\xa6\x15\x2d\xa7\xbf\xc4\xd2\xbd\x60\xba\x74\x4f\x9b\x2b\x6d\x2d\x9a\xf2\x9a\xac\x52\xee\x1e\xea\xef\x82\x9f\x0f\x23\x59\x64\x14\xb3\xe5\x0f\xbc\x21\x48\x6b\x02\x32\xa3\x98\x18\x3d\x58\xbf\x32\x4a\xd0\x88\x42\x27\x96\x38\xb6\x90\x04\x10\xf6\x3e\x00\x37\xc8\x1e\x57\x9e\x60\xc3\x38\xc4\xc7\xfd\xd2\x3c\x70\x1e\x6d\xbb\x9a\x3c\x57\x2c\xbd\xc1\xf9\xd5\x65\x51\xba\x67\x4e\x5e\xe1\xb0\xd1\x6c\x79\x4d\xb1\x56\x02\x4f\x60\xd2\x5b\x3e\x5c\xd8\x30\x14\xdf\x1b\x26\x0f\x9d\xf4\x0a\xfe\x10\x9b\x86\xab\xf5\xf0\x94\x6a\x8c\x06\x7d\x9c\x1c\xd7\x6c\x9d\x44\x7e\xb4\x3c\x64\xfe\x41\xd5\x04\xfe\xf0\xfc\xea\xf2\x25\xf3\x01\x53\xaf\x04\x94\xe0\xfe\xae\x53\xe9\x37\xf7\x77\xf4\x9f\x6e\xb6\x7c\xc3\x6f\x7f\x4f\x83\x7a\x3c\xe3\xd2\x4d\x7f\x4f\xe9\xf6\x7f\xde\x72\xf6\x27\x00\x00\xff\xff\xd6\xe9\xa4\xd0\x25\x05\x00\x00")

func doapRdfBytes() ([]byte, error) {
	return bindataRead(
		_doapRdf,
		"doap.rdf",
	)
}

func doapRdf() (*asset, error) {
	bytes, err := doapRdfBytes()
	if err != nil {
		return nil, err
	}

	info := bindataFileInfo{name: "doap.rdf", size: 1317, mode: os.FileMode(420), modTime: time.Unix(1554233711, 0)}
	a := &asset{bytes: bytes, info: info}
	return a, nil
}

var _openapiYaml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xcc\x3a\xe9\x72\x1b\x39\x73\xff\xf9\x14\x5d\x4a\xaa\xb8\x5b\x45\x0e\x0f\x4b\xb2\xc5\x54\x2a\x51\xac\xdd\x2f\xf2\x67\x47\x2a\x59\xde\x5c\x95\x6c\x35\x07\x3d\x1c\x58\x33\xc0\x2c\x80\x11\x35\x55\x79\x9f\x3c\x48\x5e\x2c\xd5\xc0\x5c\xa4\x48\x9b\x96\xa5\xdd\x9d\x3f\x12\x71\xf4\x85\x46\x5f\xe8\xf1\x78\x3c\xd0\x05\x29\x2c\xe4\x02\x5e\x45\xd3\x68\x36\x90\x2a\xd1\x8b\x01\x80\x93\x2e\xa3\x05\x7c\x2c\x97\x96\x1c\xe8\x04\x5c\x4a\x70\x2d\xd5\x52\xa3\x11\x70\x7e\x7d\x39\x00\xb8\x27\x63\xa5\x56\x0b\x18\xce\x86\x03\x00\x41\x36\x36\xb2\x70\x7e\xe8\x7f\xc6\x03\x00\x80\xdb\xad\x5d\x20\x2d\x20\xac\xb1\x02\xa7\x41\x2a\x47\x06\x63\x07\x85\xd1\x2b\x83\x39\x3a\x19\x63\x96\x55\xb0\x96\x2e\x85\x4a\x97\x06\x96\x5a\xdf\xe5\x68\xee\xec\xc8\x83\x53\xda\x91\x05\x54\x02\xb4\x4b\xc9\x74\xa0\x05\x3a\x8c\x06\x7e\xcd\xbf\xa6\x64\xe8\x9e\x0c\x14\xda\x5a\xb9\xcc\xe8\x11\xe9\x50\x5a\xb2\x7e\xd4\x62\x4e\x60\x2b\xe5\xf0\xc1\x43\xcd\xc9\xa5\x5a\x80\xc2\x9c\xd1\x58\x0f\x8f\xd7\x5d\x50\x26\x63\xa9\x4b\x0b\xbf\xcc\x18\x42\x04\x1f\x89\x40\xc8\x24\x21\x43\x2a\x26\x0b\x89\xd1\x79\x6f\x59\xa2\x0d\x20\x24\x65\x96\x41\x26\x2d\x0b\xd0\xc3\x42\x43\x68\x61\xcd\x14\x7a\xb8\xe7\xd7\x97\x16\x84\xbc\x27\xb3\xa2\x9a\xfc\x9f\x4b\x57\x1a\x82\x84\x90\xff\x5a\xc8\xb1\x82\x25\xf9\xa9\x31\x60\xe9\xf4\x38\x91\x59\x06\x5a\xc1\xa4\xd0\xd6\xd9\xc9\x8a\x9c\x47\x57\xaa\x3b\xa5\xd7\x0a\x4a\x93\xd9\x7a\xb9\xd2\x0a\x3e\xdd\xbe\x05\x27\x73\x6a\xc6\x48\xc5\x99\xb6\x1e\xf4\x0f\x32\xc7\x15\xd9\x11\xb8\xb4\xcc\x97\x0a\x65\x66\x47\x80\xa5\x90\x7a\x04\xf7\x52\x90\xfe\x71\x00\x10\x6b\xe5\x30\x76\x0b\xbf\x9d\x72\x94\xd9\x02\x8a\x5a\x98\x63\x2c\xe4\x3f\xe6\x46\x47\x2c\xb0\x70\x40\x98\xd3\x02\x36\x86\x4a\x93\x2d\x20\x75\xae\xb0\x8b\xc9\x24\xd6\x82\xa2\x66\x7a\x92\x1b\x3d\x69\x60\x1d\xdb\x14\xd1\x64\x72\x40\x0f\x8e\x8c\xc2\xec\x42\xc7\x96\xb1\xfa\xfd\x47\x51\x34\xc1\xa5\x2e\xdd\xd1\xb6\xa2\x5d\x5c\x9d\x5f\xc3\xcd\xc5\xcf\x03\x4b\x86\x15\x72\x31\x18\x6f\xa2\x14\x94\xeb\x0e\x65\x8d\x65\x7c\x3f\x8d\x8e\x67\xcb\x47\xd8\xa3\x78\x25\x27\xf7\xb3\x83\x61\xcc\xa6\xd1\xfc\xbb\x80\xac\x74\xf7\xdf\x9e\x7d\x58\xc8\xa8\x41\x11\x49\xc5\x2b\x0a\x74\xa9\x17\xce\xd1\xa4\xbe\xbf\x51\x85\x79\x76\x14\x4e\x69\x45\xf5\x71\x01\x38\x5c\xd9\xe6\xff\x31\x60\x21\xeb\xff\x6d\x99\xe7\x68\xaa\x85\xbf\x0e\x3d\x79\xd6\xd3\xba\x20\x83\xfc\xfb\x52\x2c\xc0\xcb\xfd\x2a\xa0\xa9\xe7\x0d\xd9\x42\x2b\x4b\x2d\x6c\x80\xe1\x7c\x3a\x1d\x76\x3f\xb7\x4e\x69\x37\x1a\xfe\x58\xbf\x48\xb9\xfe\x4e\x00\x47\x0f\x6e\xf2\x30\x66\x9e\x36\x27\x00\x6c\x9c\x52\x8e\xdb\xa3\x00\xae\x2a\x68\x01\xd6\x19\xa9\x56\x5e\x30\xa5\x25\xc3\xb2\xfb\xd5\xe9\x3b\x52\x5f\x13\x0d\xaf\xde\x96\xcd\x0d\xb9\xd2\xa8\x60\x29\x78\x7e\x68\x3d\x1f\x1e\x1e\xfc\xc0\x77\x2e\xc7\x3b\xa9\x56\x7e\x94\x2d\x97\xf5\x96\x4b\x97\x0e\x10\x0a\xb4\x76\xad\x8d\xf8\x31\xda\x29\xd3\x42\xde\x32\x98\x7a\xae\x40\x83\x39\x39\xaf\xbe\x0d\x45\x47\x7f\x6b\x28\x39\x5a\xc0\xd1\xdf\x4c\x62\x9d\x17\x5a\x91\x72\x76\xd2\xad\x9c\x24\xda\xe4\xe8\xae\x79\xe0\xe8\xc9\xe7\xc2\x26\x25\x1d\xbb\x1e\x2d\x7b\x0f\x05\x8b\x22\x93\xb1\x67\x61\xf2\x70\xf8\xc1\xec\x58\x0a\xad\xa5\x30\x64\xcb\xcc\x7d\xed\x30\x37\x3f\x7a\xc0\xbc\x60\xe7\xf4\x6f\x57\x7f\x79\x73\xfa\xd3\xeb\x77\x97\xff\xfe\xe1\x72\x2f\xa5\x9f\xad\x56\x87\x92\x5a\x18\x3e\x24\x27\xfb\xf2\xeb\xbe\x40\xeb\xae\x99\xaf\xd1\x34\x3c\x99\xce\xf6\x9f\x41\xed\x2a\x9b\xef\x2d\x2a\xa5\x1d\x38\xca\x32\xc0\xa0\x6c\x11\x5c\xe6\xb8\x92\x8a\x00\xc1\x3a\x74\x94\x91\xb5\x90\xe0\xff\xfd\x2f\x0a\x82\x54\x0a\x56\x42\x84\xda\x90\xc0\x12\xe3\x3b\x52\x02\x5c\x8a\x0e\x52\xb4\xa0\x34\x48\x41\xc8\x6e\xdc\x96\x71\xca\x60\x53\xbf\x85\x7d\x69\xa7\xd3\xd1\x06\x1d\x57\x2a\xab\x40\x26\x5e\x41\x60\x8d\x96\xb7\xc6\x64\x6d\x52\x66\xa3\xe0\x7e\xd7\xd2\x12\xbb\x69\xbe\x55\x80\x70\x3c\x9d\x4d\x8e\xa7\xaf\xa2\x83\x2e\x77\x91\xa1\x3c\xf8\x5c\x0e\x53\x87\x93\xe9\xac\xbe\x9b\x9e\x64\x16\xa2\x2d\x8b\x42\x1b\x47\x62\xc4\x17\x18\xfe\xf9\xf6\xf6\x1a\xfe\x09\xad\x8c\x23\x6f\x22\x5a\x07\xfa\x35\xeb\xe0\x17\xee\x33\x0f\x5a\x11\xb0\x2d\xd0\x86\xc2\x42\x76\xce\x08\x56\xaa\x55\x46\x20\xb0\x82\x1c\x5d\xec\x05\xce\x96\x04\xcd\xaa\xcc\xf9\x2e\x47\x70\x99\xb4\xdc\x28\xcd\x31\x8c\x07\x54\x9a\x8c\xc3\xa4\x95\xbc\x27\x35\xaa\x47\x13\xc8\xb5\x75\x60\x28\x26\xe5\xda\xa8\x08\xd6\x1c\x0a\x2c\xbd\x75\x12\x8d\xdc\x37\x74\xeb\x32\xf1\x86\x89\x44\x88\xa9\x10\x2a\x72\x6d\xa4\xf0\xe9\xe6\xfd\xc8\x7b\x19\xf8\xcb\x4f\xb7\x9e\x36\x1f\xf5\xf9\x48\x68\x55\x92\xb5\x2d\x75\x2c\x8d\x10\xe2\xf0\xaa\x4f\x37\xef\x77\xd9\xb4\x15\xb9\x6b\x6d\xdd\x33\x9b\xb4\x71\x6d\x29\x1c\x76\x47\x2f\xd5\x02\x7e\x2b\xc9\x54\x83\x9d\x17\x2a\x91\x99\x23\x03\xcb\x0a\xca\x82\xe3\x4c\x97\x1a\x22\xcf\xc3\x60\xbf\xa6\xed\xa6\x2d\xac\xb3\x13\x87\xab\x6d\x82\x84\x3b\x94\x1e\xe3\x15\xa5\xb6\x1f\xb6\x3d\x3d\x12\xac\x28\x2e\x95\x96\x95\xe4\xc9\xa4\xb1\x82\x1c\x1d\x2e\xe2\xd2\x64\x5e\xbe\x57\x85\xdb\xe6\x28\x27\x87\x87\xf2\x24\x55\x9c\x95\x82\x0d\x52\x9c\xa2\x5a\x11\x08\x72\x14\xf3\x1c\x58\xb9\x52\x3e\x78\x05\xc9\xf7\x80\xa1\x02\x3a\x67\xe4\xb2\x74\xf4\x64\x36\x2b\xb2\xbf\x2a\xfd\x64\x47\x97\x68\xfd\x42\xfe\xed\xcb\x64\x7b\x83\x70\xd4\xb3\x36\x28\xc4\x93\xac\xcd\xb9\x10\x80\xad\xee\x44\x70\xde\x98\x91\x70\xb3\x6d\x8a\x82\x84\x37\xfd\x2b\xa3\x4b\x25\x38\xd7\x00\x43\xbf\x95\xd2\x74\xb6\x61\x33\x06\x11\xe2\x45\xee\xeb\xc1\x2a\xf8\xe8\x46\xed\x08\x10\xbf\xa2\x86\xb7\xde\x60\xd5\xe9\xa9\x74\x94\x47\x70\xcb\x17\x2a\x91\x94\x09\xb6\xa2\xa5\x4a\xb4\x71\xa5\x62\xa7\x59\x79\x3c\x02\x86\x3d\x10\xc3\xde\x79\x72\x44\xc7\x02\x5c\xa3\x11\x16\x98\x76\x74\x72\x29\x33\xe9\xea\x94\x94\x91\x88\x36\xc7\x0b\x49\x70\xa3\x8f\x41\xd0\x0b\x70\xa6\x7c\xba\x92\x7b\xfb\xbb\x2d\x16\x4e\x88\x94\x20\x71\xa8\x4c\x2e\xba\x1f\x9b\x92\x79\x1b\x3c\xc1\xb0\x01\x38\xfc\x12\xc7\x3d\x9a\x3d\xef\xbb\xf9\xfe\x66\x06\xe9\xe1\x91\xd9\xd9\x30\xcc\x5f\xe1\xed\x7d\x48\xa6\x6b\xc3\x3e\x9b\x4e\xff\x40\xb3\x1e\x1b\xf2\x77\xc9\xa7\xd7\x5e\x94\xde\x96\x77\x57\xf4\x82\x12\xf4\x16\xdf\x69\x88\x4b\x63\xd8\x75\xf3\xda\x08\x2e\xd0\x91\x75\x98\x17\xb6\x47\xaa\x0f\x20\x5c\x8a\x0a\x66\x53\xc8\xa5\x2a\x7d\xad\x23\x25\x14\x3e\x70\xf3\xe9\x6d\xc0\xd5\xb8\x7d\x43\x96\x5c\x1f\x7a\x58\xd4\x83\xc9\xcb\xbf\xcb\xb3\x30\x80\x6d\x11\x19\x2a\x32\x8c\xe9\x50\x39\xdd\x84\xe5\x80\xaa\x02\x7a\x90\xd6\x71\x24\xd4\x0b\x61\xfc\xc5\x92\x96\xc3\x8a\x56\x66\x7c\x75\x2b\xb2\xfd\x58\xf2\x92\x65\xe0\xb9\x55\x7a\x14\x44\xe0\x52\xa3\xd7\x1c\xc2\x92\x31\xda\x70\xb0\xda\x82\xf5\x88\x9e\xae\x18\x9b\xde\xa6\x61\xdc\xa6\x68\x0e\xbf\x87\x1f\xf0\x8e\x3a\x82\x8a\x72\x99\xc9\x78\x83\xc1\x61\x45\x76\x08\xa5\xf2\xd1\x3c\x27\x98\x3e\x58\x27\x85\xcb\xac\x87\x25\x54\xa0\x86\x16\xef\x09\x90\xcf\xbd\x29\x89\x71\xf8\x5e\x18\x79\x8f\x8e\x86\x61\xbb\x25\xc7\xc2\x1d\xb1\xe3\x5d\xa7\x32\x4e\x21\x46\xcb\x26\xcb\xa3\xec\x41\x64\xe4\x4a\x0f\x9f\x59\x3c\x4e\x1b\xc2\x6f\x10\x0f\xf3\xc0\xbc\xb5\x32\x42\xb6\xd7\x0c\x63\x53\x4c\xcf\x47\xe9\x21\x61\xc3\x6e\x78\xed\xce\xc9\x85\x56\xd4\x77\xe8\x82\x32\x72\xf4\x24\x9f\x7e\xe1\xb7\xf6\xdd\xfa\x2e\x3f\x1d\x10\xfc\x39\x5c\xf5\xf3\x0a\x90\x45\x74\x48\xfa\xd5\xb3\xf1\x8f\xb2\xaf\xcd\x2a\x6b\xbf\x56\xe3\x93\x16\xcc\xb4\x5a\x75\xde\x5b\x95\xf9\x92\x8c\x5f\xd8\xd6\x44\xeb\x2b\x56\xc1\x9a\xcc\x66\x2a\xb5\x9d\xde\xdc\x76\x74\xfc\x71\x15\x1b\xb6\x7b\x7d\x9f\xf7\x3b\x56\x6b\xb6\xf0\xd6\x92\xf8\x62\xd1\xc4\xe1\x6a\x77\xc5\x24\xe4\xf4\x68\x0c\x56\x3b\xe7\x39\x64\xd9\x09\xb1\xdb\xac\x97\x9f\x29\x7e\x5c\x3c\x3a\x84\xac\xf0\xc5\xba\xdc\x16\xdb\xe6\xb7\x47\x1a\xdd\xd7\xe6\x35\x5b\x81\xdf\x3e\x92\xa5\x72\xb4\xda\xf0\xd0\xdb\x5f\x57\xce\xd8\xbb\x68\xaf\x4c\x9f\x9d\xe8\x96\x98\x90\x3e\xd5\x37\xf6\x30\x8b\xb7\xeb\xd2\x36\x06\x4f\x75\x81\x80\xc3\xd5\x17\xac\xde\x6d\x9b\xf5\xff\xbe\xf5\x84\xe7\x8a\xe7\xbb\xf8\xf2\x05\x2c\xa7\x21\x66\xe5\x69\xc6\x93\x77\xf2\x39\x38\x5c\x8d\x40\x1b\x48\x34\xe7\x4b\x8e\x23\x07\xa7\x0f\x3a\xa0\x80\xfd\xe5\x0e\x48\x67\x07\x87\x12\x43\xa5\x59\x99\x7d\x99\x8d\x23\x06\xa5\x5d\x88\x7c\x2c\x29\x2b\x9d\xbc\xa7\xe1\x0b\x9e\x6c\x43\xb0\xa2\xf5\xa1\x04\xcb\x04\x28\x2f\x5c\x35\x62\x52\x7d\x65\xd0\xc7\xb3\x29\x16\x45\xaf\x0e\xff\x67\x56\xc2\x6e\x8a\xf7\x6e\x9f\x7d\xef\x58\x1b\xd0\x41\x46\x61\x62\xb0\x5b\x48\xdb\x8c\x7d\x99\xad\x00\x2a\x70\xd6\x84\x29\x9b\xc8\x4a\x93\xed\xc1\xb4\x53\xb2\xdf\x86\xfe\xd3\xcd\xfb\x4d\xdc\x57\x85\x3b\x14\xfd\x93\x30\x6d\x9d\x1d\x1f\x43\x03\x61\xeb\x49\x47\xb1\x64\xb2\x76\x43\xbd\xe8\x51\x94\xf0\x85\x08\x61\x57\x74\xb0\xc3\xad\xec\x7d\xc3\xd9\xef\x7c\x63\x2d\xe8\xb1\x77\x6a\xfc\xcc\xa3\x89\x31\x08\xad\x1e\x7b\xa8\x31\x58\x9d\x53\x7d\x73\x38\xf1\x5d\x1b\xbd\xe3\x69\x60\x8f\x27\x7c\xec\xff\x6a\x51\x87\xc5\x3d\xf7\xba\xe3\xe1\x61\x43\xd6\xa1\x0c\x31\x3f\x39\x81\x38\x45\x83\x31\xdf\x80\x08\x3e\x60\x15\x4c\x90\x56\x0e\xa5\x82\x58\xe7\x39\x5a\xb6\xb2\xeb\x54\x3a\xb2\x05\xc6\x14\xc1\x75\x46\x68\x3b\xd6\x96\x04\xb8\xc6\x50\x01\x70\x21\x76\x5d\xd2\x4a\x2a\x25\x9b\xf8\x15\xa1\x20\x23\x75\xa8\xec\x39\x43\xe8\x48\xf4\x72\x40\x5f\xb6\x77\x46\xae\xfa\xf1\x85\x2d\x28\x96\x98\xb5\x6b\x1c\xae\xc0\x52\x8e\xca\xc9\xb8\xce\xae\x3f\xdd\xbc\x3f\x94\x5d\xb4\x9c\x4b\x4a\x45\x02\x96\x15\xdc\xfc\xfc\x16\x5e\x9d\xbd\x39\x8d\x38\x26\xd5\x6b\x12\x41\x8c\x64\x3d\x81\xa9\x73\x45\x78\x5d\xb0\x23\xf8\x8c\xf7\x18\xe0\x8c\x5a\xd2\x72\x94\x99\xd3\x23\x48\x5c\xe1\x49\x4f\x64\x46\x91\x6f\x18\xf9\x88\x09\x1a\x39\xf6\xb4\x27\x32\x86\x84\x5a\xd8\xbe\x7f\xa4\xc6\xb6\x2c\x9d\x37\x9b\x7d\x11\xf6\xc4\x82\x60\x2b\xa5\x55\x95\xfb\xd2\x0c\x13\x12\xf8\x0d\x9d\x2d\xdf\x71\xc0\x90\x35\xba\xe6\xe8\xc1\x7d\x1b\xa4\xd3\x93\x93\x57\xa7\xdb\xb0\x22\x38\x57\x15\x9f\x83\x6d\x2b\x3b\xbe\xd1\x23\x93\xea\x4e\x26\x92\x04\xac\xd3\x9e\x5b\x10\xd2\x16\x19\x56\x4d\x9a\xd2\x54\x69\x0e\x25\x64\xd8\x76\x84\x38\xcc\x0b\xef\xf0\x7d\xc5\xd4\x9b\xd3\x05\xcc\xa7\xb3\xe9\x78\x36\x1f\xcf\x66\xb7\xb3\xb3\xc5\xf1\x9b\xc5\x74\xfe\x1f\x11\xfc\x82\x99\xf4\x5d\x36\x9d\xc2\x1a\x5f\xf6\x97\x16\xde\xa1\x82\xd9\x08\x66\x70\x7e\xc1\x2c\xbe\x43\x55\xa2\xa9\x78\x68\x3e\x9b\x4e\xe1\x07\x3e\x27\x4b\xe4\x9b\x77\x60\x49\x99\x5e\x87\x96\x02\x48\x42\xab\x4b\x4b\x8b\xfd\x31\x6a\xfc\x74\x1b\x78\x0e\x77\xd1\x33\x6c\x19\xff\x26\xa6\xfd\x53\xda\x7e\x7e\x23\xf8\xc8\x41\x51\xe0\x8b\x55\xbd\x16\x2c\x53\xdb\x45\x10\xbb\x28\x0b\x73\xa1\xd4\x70\x28\x45\x9c\x8d\x66\xd2\x91\xc1\xac\x5e\x55\xd7\x83\xb4\xe9\x57\x3c\x3a\x74\xcd\x58\x2e\x4e\x0e\xc5\xf1\x6a\xde\xa9\x1a\xa4\xf4\x80\x82\x62\xc9\xae\xe1\xc3\xc5\x09\xa4\x68\x53\xbf\xa9\xce\x49\x36\x81\x6e\x26\x2a\x5b\x4f\x3f\x7e\x2a\x48\xb2\x91\xd7\x34\x8a\xe6\xff\xfd\x6a\xde\xf3\xfe\xdf\x23\x88\xcf\x56\xab\x20\x89\x87\x3c\x7b\x24\x0a\x9e\x6d\x21\xf9\x32\xd1\x82\x4d\xbd\x1f\xf2\x75\x96\x4d\xd4\xfd\x1c\x73\x97\x63\x12\x1b\x29\xe0\x9e\xb7\xe6\x47\xae\x64\x7f\x12\x75\x88\xee\xc2\xe3\x34\xee\x19\x11\xaf\x69\x29\xe8\xbe\x9d\x29\x6d\x77\xba\xcf\x8c\xa9\xd7\x36\xb3\x95\xb0\xef\x4c\xd1\xb7\x50\x84\xf8\xa1\xe8\x2a\x5b\xfb\x22\x87\xd4\x50\xb2\xd5\x44\xb0\xaf\x25\x60\x87\xcf\xff\x52\xbe\xdb\x72\xc2\x0e\x62\x31\x99\xac\xd7\xeb\x28\xd5\x6b\xa7\x7d\x75\x9f\xa2\x58\x47\xe5\xdd\xc4\x95\x4e\x1b\x89\x59\x78\xc0\x20\x23\x2d\x45\x45\x5a\xfc\x83\xd0\xf9\xdf\xcf\x7a\xf0\xfa\x8a\xfd\xb2\xe4\xbe\xc3\x7b\xfc\xe8\x71\xc1\xc5\xd5\x07\x30\x54\x77\x22\x6e\x1c\x52\x78\xe6\x79\x61\x4a\x84\xce\x77\xa2\x67\xfb\xf2\xc2\xa8\xe3\xe9\xfc\xd5\x1b\x11\x4f\xe3\xe3\xe3\x64\xfa\x5a\x20\x09\x71\x86\xb3\x44\x9c\x2d\x97\x82\x68\xd9\xaf\xa1\xe7\xe4\xf0\x85\xa9\x39\x9b\x9f\x9d\x9c\xe1\xd9\x69\x22\x4e\xcf\x66\xc7\xa7\xf1\x49\x42\xaf\xe3\xa5\xa0\x53\x3a\x79\x3d\x9f\x26\xf3\xde\x2e\xdf\x3e\x63\x5f\x98\x9e\x93\x7e\x05\xe9\x51\xcd\xe8\x45\xf4\xa0\x8b\xf3\xb6\x6d\x50\x78\x8a\x7a\x61\x12\x38\x97\x3d\x19\xcf\x66\xe3\xf9\x9b\xdb\xe9\xc9\x62\x7e\xba\x98\x9e\x45\xd3\xe9\xd4\x1b\x5d\x4b\x71\x69\xa4\xab\x3e\x86\x20\x35\x20\xf0\xcd\x40\xe7\xa5\x4b\x37\x5d\x06\x1b\x83\x7e\xae\x46\x0b\x58\xf2\x4a\x3f\xc6\xcb\x7d\xf3\xde\x96\x9b\x29\xe4\x5f\xa9\xda\x93\xf1\x05\x4b\x87\xa5\x4b\x7f\xed\xf7\xda\xed\xeb\x03\x3b\x0f\x3d\x4c\xc4\x61\x7a\xfd\xc6\xe8\x1b\x9b\x7c\xf7\xb4\x4d\xb5\x71\xa0\x0b\xfc\xad\x24\x90\x82\x17\x25\xb2\xf3\xc7\xec\x7d\x5b\x40\x43\x36\xd0\x1e\xf9\xed\xd5\x5f\x7f\xfa\x97\x61\xdd\x64\xcc\xdf\x27\xcb\xf1\x67\x8c\x0a\x12\xe9\x9b\xc5\x48\x9a\x5e\x7f\xa3\x6f\x4d\xe1\xa1\xfa\x51\xc9\x42\x81\xab\x10\xa0\x57\x7e\x17\xe7\xd0\xd4\xb3\xdc\x08\x8a\xd6\x4d\x03\x96\xf3\xaf\x7d\x7c\xe4\x7f\x17\xc2\x2d\x1f\xd8\x4a\x75\xcf\x71\xa4\x4f\x44\x3c\xec\xc2\xd0\x7d\xf3\xa6\xdc\xf4\xa0\xf5\x84\x50\xf1\x5e\x23\xc6\x05\x1a\x57\x81\xe5\xdc\xa9\xdf\x72\x59\x53\xe0\xfb\xad\x96\x94\x62\x96\x80\x4e\xba\x4e\x6f\x66\xbd\x2b\x57\xfb\xee\x25\x54\xa0\x4b\x67\xa5\xa0\xe6\x3d\xf5\xc3\xa7\x8f\xb7\xbe\x37\xcc\x53\xb9\x25\xf4\xba\x1d\x5c\x2a\xeb\xc2\x2b\x6c\x97\x57\xb1\x3f\xa8\x1b\xba\xea\xe7\x86\xa6\xe1\x33\x82\x5f\xa4\xce\xd0\x69\xd3\x85\xf3\xcb\x4c\xc7\x77\x24\x02\x11\xa5\xad\x77\x76\x9c\x5e\x5f\x46\x83\x46\x3f\x17\x83\x71\x4f\xc5\xe0\x3f\xff\x6b\x30\xee\xa9\x29\xff\x0e\xf5\xbd\xa6\xec\x14\xba\x72\xb7\xbb\xa6\xb7\xfa\xae\xfb\xfd\xc3\x4d\x1b\xef\x66\x23\x45\x4a\xa0\x8d\x5c\x49\x85\xd9\x66\x9f\xff\xff\x07\x00\x00\xff\xff\x21\xda\xaa\x31\x25\x30\x00\x00")

func openapiYamlBytes() ([]byte, error) {
	return bindataRead(
		_openapiYaml,
		"openapi.yaml",
	)
}

func openapiYaml() (*asset, error) {
	bytes, err := openapiYamlBytes()
	if err != nil {
		return nil, err
	}

	info := bindataFileInfo{name: "openapi.yaml", size: 12325, mode: os.FileMode(420), modTime: time.Unix(1554236102, 0)}
	a := &asset{bytes: bytes, info: info}
	return a, nil
}

// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
	cannonicalName := strings.Replace(name, "\\", "/", -1)
	if f, ok := _bindata[cannonicalName]; ok {
		a, err := f()
		if err != nil {
			return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
		}
		return a.bytes, nil
	}
	return nil, fmt.Errorf("Asset %s not found", name)
}

// MustAsset is like Asset but panics when Asset would return an error.
// It simplifies safe initialization of global variables.
func MustAsset(name string) []byte {
	a, err := Asset(name)
	if err != nil {
		panic("asset: Asset(" + name + "): " + err.Error())
	}

	return a
}

// AssetInfo loads and returns the asset info for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func AssetInfo(name string) (os.FileInfo, error) {
	cannonicalName := strings.Replace(name, "\\", "/", -1)
	if f, ok := _bindata[cannonicalName]; ok {
		a, err := f()
		if err != nil {
			return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
		}
		return a.info, nil
	}
	return nil, fmt.Errorf("AssetInfo %s not found", name)
}

// AssetNames returns the names of the assets.
func AssetNames() []string {
	names := make([]string, 0, len(_bindata))
	for name := range _bindata {
		names = append(names, name)
	}
	return names
}

// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() (*asset, error){
	"doap.rdf":     doapRdf,
	"openapi.yaml": openapiYaml,
}

// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
// For example if you run go-bindata on data/... and data contains the
// following hierarchy:
//     data/
//       foo.txt
//       img/
//         a.png
//         b.png
// then AssetDir("data") would return []string{"foo.txt", "img"}
// AssetDir("data/img") would return []string{"a.png", "b.png"}
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
// AssetDir("") will return []string{"data"}.
func AssetDir(name string) ([]string, error) {
	node := _bintree
	if len(name) != 0 {
		cannonicalName := strings.Replace(name, "\\", "/", -1)
		pathList := strings.Split(cannonicalName, "/")
		for _, p := range pathList {
			node = node.Children[p]
			if node == nil {
				return nil, fmt.Errorf("Asset %s not found", name)
			}
		}
	}
	if node.Func != nil {
		return nil, fmt.Errorf("Asset %s not found", name)
	}
	rv := make([]string, 0, len(node.Children))
	for childName := range node.Children {
		rv = append(rv, childName)
	}
	return rv, nil
}

type bintree struct {
	Func     func() (*asset, error)
	Children map[string]*bintree
}

var _bintree = &bintree{nil, map[string]*bintree{
	"doap.rdf":     &bintree{doapRdf, map[string]*bintree{}},
	"openapi.yaml": &bintree{openapiYaml, map[string]*bintree{}},
}}

// RestoreAsset restores an asset under the given directory
func RestoreAsset(dir, name string) error {
	data, err := Asset(name)
	if err != nil {
		return err
	}
	info, err := AssetInfo(name)
	if err != nil {
		return err
	}
	err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
	if err != nil {
		return err
	}
	err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode())
	if err != nil {
		return err
	}
	err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
	if err != nil {
		return err
	}
	return nil
}

// RestoreAssets restores an asset under the given directory recursively
func RestoreAssets(dir, name string) error {
	children, err := AssetDir(name)
	// File
	if err != nil {
		return RestoreAsset(dir, name)
	}
	// Dir
	for _, child := range children {
		err = RestoreAssets(dir, filepath.Join(name, child))
		if err != nil {
			return err
		}
	}
	return nil
}

func _filePath(dir, name string) string {
	cannonicalName := strings.Replace(name, "\\", "/", -1)
	return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
}

R pinboard.sh => build.sh +8 -5
@@ 13,13 13,16 @@ parm="" # "-u"
{
  "${say}" "go get"
    go get ${parm} \
    github.com/jteeuwen/go-bindata/... \
    golang.org/x/net/html \
    \
    github.com/stretchr/testify \
    github.com/yhat/scrape
}

PROG_NAME="pinboard"
"$(go env GOPATH)/bin/go-bindata" doap.rdf openapi.yaml

PROG_NAME="pinboard4shaarli"
VERSION="$(grep -F 'version = ' version.go | cut -d \" -f 2)"

rm "${PROG_NAME}"-*-"${VERSION}" 2>/dev/null


@@ 30,8 33,9 @@ go fmt && go vet && go test --short || { exit $?; }
"${say}" "ok"

"${say}" "build localhost"
go build -ldflags "-s -w -X main.GitSHA1=$(git rev-parse --short HEAD)" -o ~/public_html/b/pinboard.cgi || { echo "Aua" 1>&2 && exit 1; }
go build -ldflags "-s -w -X main.GitSHA1=$(git rev-parse --short HEAD)" -o ~/"public_html/b/${PROG_NAME}.cgi" || { echo "Aua" 1>&2 && exit 1; }
"${say}" "ok"
ls -l ~/"public_html/b/${PROG_NAME}.cgi"
# open "http://localhost/~$(whoami)/b/pinboard.cgi"

"${say}" bench


@@ 45,13 49,12 @@ env GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.GitSHA1=$(git rev-p
# env GOOS=linux GOARCH=386 GO386=387 go build -o "${PROG_NAME}-linux-386-${VERSION}" # https://github.com/golang/go/issues/11631
# env GOOS=darwin GOARCH=amd64 go build -o "${PROG_NAME}-darwin-amd64-${VERSION}"


"${say}" "simply"
# scp "ServerInfo.cgi" simply:/var/www/lighttpd/h4u.r-2.eu/public_html/"info.cgi"
gzip --force --best "${PROG_NAME}"-*-"${VERSION}" \
&& chmod a-x "${PROG_NAME}"-*-"${VERSION}.gz" \
&& rsync -vp --bwlimit=1234 "${PROG_NAME}"-*-"${VERSION}.gz" "simply:/tmp/" \
&& ssh simply "sh -c 'cd /var/www/lighttpd/demo.mro.name/ && gunzip < "/tmp/${PROG_NAME}-linux-amd64-${VERSION}.gz" > pinboard.cgi && chmod a+x pinboard.cgi && ls -l pinboard?cgi*'"
&& ssh simply "sh -c 'cd /var/www/lighttpd/demo.mro.name/ && gunzip < /tmp/${PROG_NAME}-linux-amd64-${VERSION}.gz > ${PROG_NAME}.cgi && chmod a+x ${PROG_NAME}.cgi && ls -l ${PROG_NAME}?cgi*'"

# ssh simply "sh -c 'cd /var/www/lighttpd/b.mro.name/public_html/u/ && cp /var/www/lighttpd/l.mro.name/public_html/pinboard?cgi* . && ls -l pinboard?cgi*'"
"${say}" "ok"


@@ 60,6 63,6 @@ exit 0

"${say}" "vario"
# scp "ServerInfo.cgi" vario:~/mro.name/webroot/b/"info.cgi"
ssh vario "sh -c 'cd ~/mro.name/webroot/b/ && curl -L http://purl.mro.name/pinboard_cgi.gz | tee pinboard_cgi.gz | gunzip > pinboard.cgi && chmod a+x pinboard.cgi && ls -l pinboard?cgi*'"
ssh vario "sh -c 'cd ~/mro.name/webroot/b/ && curl -L http://purl.mro.name/${PROG_NAME}_cgi.gz | tee ${PROG_NAME}_cgi.gz | gunzip > ${PROG_NAME}.cgi && chmod a+x ${PROG_NAME}.cgi && ls -l ${PROG_NAME}?cgi*'"
"${say}" "ok"


M doap.rdf => doap.rdf +13 -11
@@ 2,20 2,22 @@
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns="http://usefulinc.com/ns/doap#">
  <Project>
    <bug-database rdf:resource="https://code.mro.name/mro/Shaarli-API-test/issues"/>
    <homepage rdf:resource="https://code.mro.name/mro/Shaarli-API-test/"/>
    <license rdf:resource="https://code.mro.name/mro/Shaarli-API-test/src/master/LICENSE"/>
    <maintainer rdf:resource="http://mro.name/~me"/>
    <name>Shaarli-API-test</name>
    <programming-language>shell</programming-language>
    <bug-database rdf:resource="https://code.mro.name/mro/pin4sha/issues"/>
    <homepage rdf:resource="https://code.mro.name/mro/pin4sha/"/>
		<implements rdf:resource="pinboard.in/v1/openapi.yaml"/>
    <license rdf:resource="LICENSE"/>
    <maintainer rdf:resource="https://code.mro.name/mro"/>
    <name>pin4sha.cgi</name>
    <programming-language>golang</programming-language>
    <programming-language>ocaml</programming-language>
    <repository>
      <GitRepository>
        <browse rdf:resource="https://code.mro.name/mro/Shaarli-API-test"/>
        <location rdf:resource="https://code.mro.name/mro/Shaarli-API-test.git"/>
        <browse rdf:resource="https://code.mro.name/mro/pin4sha"/>
        <location rdf:resource="https://code.mro.name/mro/pin4sha.git"/>
      </GitRepository>
    </repository>
    <short-description>Run sh test scripts against multiple PHP &amp; http://github.com/shaarli/Shaarli versions.</short-description>
    <wiki rdf:resource="https://code.mro.name/mro/Shaarli-API-test/wiki"/>
    <short-description xml:lang="en">Make the essential subset for posting to a shaarli available via a subset of the pinboard/delicious API.</short-description>
    <short-description xml:lang="fr">Rendre le sous-ensemble essentiel pour l'affichage dans un shaarli disponible via un sous-ensemble de l'API pinboard/delicious mature et éprouvée.</short-description>
    <wiki rdf:resource="https://code.mro.name/mro/pin4sha/wiki"/>
  </Project>
</rdf:RDF>
\ No newline at end of file
</rdf:RDF>

A model.go => model.go +48 -0
@@ 0,0 1,48 @@
//
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

package main

import (
	"encoding/xml"
)

type Result struct {
	XMLName xml.Name `xml:"result" json:"-"`
	Code    string   `xml:"code,attr,omitempty" json:"result_code"`
}

type Post struct {
	Href        string `xml:"href,attr" json:"href"`
	Description string `xml:"description,attr" json:"description"`
	Extended    string `xml:"extended,attr" json:"extended"`
	Hash        string `xml:"hash,attr" json:"hash"`
	Meta        string `xml:"meta,attr,omitempty" json:"meta"`
	Others      int    `xml:"others,attr" json:"others"`
	Tag         string `xml:"tag,attr" json:"tag"`
	Time        string `xml:"time,attr" json:"time"`
	Shared      string `xml:"shared,attr,omitempty" json:"shared,omitempty"`
	Toread      string `xml:"toread,attr,omitempty" json:"toread,omitempty"`
}

type Posts struct {
	XMLName xml.Name `xml:"posts" json:"-"`
	User    string   `xml:"user,attr" json:"user"`
	Dt      string   `xml:"dt,attr" json:"date"`
	Tag     string   `xml:"tag,attr,omitempty" json:"tag,omitempty"`
	Posts   []Post   `xml:"post" json:"posts"`
}

A model_test.go => model_test.go +74 -0
@@ 0,0 1,74 @@
//
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//

package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"encoding/xml"

	"github.com/stretchr/testify/assert"
	"testing"
)

// api_token {"result":"692D3D4BD4A2825D5A4A"}
// add {"result_code":"item already exists"}
// {"result_code":"done"}

// get {"date":"2018-04-09T19:26:54Z","user":"mro","posts":[]}

func xm(o interface{}) string {
	var b bytes.Buffer
	w := bufio.NewWriter(&b)
	enc := xml.NewEncoder(w)
	enc.Encode(o)
	enc.Flush()
	return string(b.Bytes())
}

func js(o interface{}) string {
	var b bytes.Buffer
	w := bufio.NewWriter(&b)
	enc := json.NewEncoder(w)
	enc.Encode(o)
	w.Flush()
	return string(b.Bytes())
}

func TestXmlEncodeResult(t *testing.T) {
	t.Parallel()
	r := Result{Code: "done"}
	assert.Equal(t, "<result code=\"done\"></result>", xm(r), "ach")
	assert.Equal(t, "{\"result_code\":\"done\"}\n", js(r), "ach")
}

func TestXmlEncodePosts(t *testing.T) {
	t.Parallel()

	assert.Equal(t, "<posts user=\"\" dt=\"\"><post href=\"uhu\" description=\"\" extended=\"\" hash=\"\" others=\"0\" tag=\"\" time=\"\"></post><post href=\"aha\" description=\"\" extended=\"\" hash=\"\" others=\"0\" tag=\"\" time=\"\"></post></posts>",
		xm(Posts{Posts: []Post{
			Post{Href: "uhu"},
			Post{Href: "aha"},
		}}), "ach")
	assert.Equal(t, "{\"user\":\"\",\"date\":\"\",\"posts\":[{\"href\":\"uhu\",\"description\":\"\",\"extended\":\"\",\"hash\":\"\",\"meta\":\"\",\"others\":0,\"tag\":\"\",\"time\":\"\"},{\"href\":\"aha\",\"description\":\"\",\"extended\":\"\",\"hash\":\"\",\"meta\":\"\",\"others\":0,\"tag\":\"\",\"time\":\"\"}]}\n",
		js(Posts{Posts: []Post{
			Post{Href: "uhu"},
			Post{Href: "aha"},
		}}), "ach")
}

A openapi.yaml => openapi.yaml +412 -0
@@ 0,0 1,412 @@
---
openapi: 3.0.1
info:
  title: Subset of the Pinboard API
  version: '1'
  description: |-
    The Pinboard API is a way to interact programatically with your bookmarks,
    notes and other Pinboard data.

    Wherever possible the Pinboard API uses the same syntax and method names as
    the Delicious V1 API. See differences from Delicious for a full list of
    areas where the APIs diverge.

    Future features may be
    - auto-fill on /posts/get for unknown urls
    - non UTC times
    - enclosures (images, thumbnails, audio, video)
  contact:
    email: pinboard-api@mro.name
    name: mro.name
    url: https://code.mro.name/mro/pinboard4shaarli
externalDocs:
  url: "../about"
  description: DOAP RDF
servers:
- url: https://demo.mro.name/shaarli-v0.41b/pinboard4shaarli.cgi/v1
- url: https://demo.mro.name/shaarli-v0.10.2/pinboard4shaarli.cgi/v1
- url: https://demo.mro.name/shaarligo/shaarligo.cgi/v1
- url: https://api.pinboard.in/v1
paths:
  "/openapi.yaml":
    get:
      tags:
      - api
      summary: API description
      operationId: aboutOpenapi
      responses:
        '200':
          description: API description
          content:
            text/x-yaml:
              schema:
                type: string
  "/user/api_token":
    get:
      tags:
      - user
      summary: Returns the user's API token (for making API calls without a password).
      operationId: apiToken
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      responses:
        '200':
          description: A auth-token
          content:
            application/xml:
              schema:
                xml:
                  name: result
                type: string
                example: XOG86E7JIYMI
            application/json:
              schema:
                properties:
                  result:
                    example: XOG86E7JIYMI
        '501':
          description: |-
            Cannot tell a token. Imagine a stateless façade hiding a shaarli backend that has no idea of such a thing as an API token.
            Only if auth was successful, otherwise you get a 401/403.
          content:
            text/plain:
              schema:
                type: string
                example: 501 token auth not supported, use HTTP Basic.
  "/posts/get":
    get:
      tags:
      - posts
      summary: Returns one or more posts on a single day matching the arguments. If
        no date or url is given, date of most recent bookmark will be used.
      description: If called with a yet unknown URL, http GET the title and guess
        tags from the URL
      operationId: getPost
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      - name: tag
        in: query
        description: filter by up to three tags
        schema:
          "$ref": "#/components/schemas/tag"
      - name: dt
        in: query
        description: return results bookmarked on this day
        schema:
          "$ref": "#/components/schemas/date"
      - "$ref": "#/components/parameters/urlParamOpt"
      - name: meta
        in: query
        description: include a change detection signature in a meta attribute
        schema:
          "$ref": "#/components/schemas/yes_no"
      responses:
        '200':
          description: foo
          content:
            application/xml:
              schema:
                "$ref": "#/components/schemas/posts"
  "/posts/add":
    get:
      tags:
      - posts
      summary: Add a bookmark. Arguments with shaded background are required.
      operationId: addPost
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      - "$ref": "#/components/parameters/urlParam"
      - name: description
        in: query
        description: Title of the item. This field is unfortunately named 'description'
          for backwards compatibility with the delicious API
        required: true
        schema:
          "$ref": "#/components/schemas/title"
      - name: extended
        in: query
        description: Description of the item. Called 'extended' for backwards compatibility
          with delicious API
        schema:
          "$ref": "#/components/schemas/text"
      - name: tags
        in: query
        description: List of up to 100 tags
        schema:
          "$ref": "#/components/schemas/tag"
      - name: dt
        in: query
        description: creation time for this bookmark. Defaults to current time. Datestamps
          more than 10 minutes ahead of server time will be reset to current server
          time
        schema:
          "$ref": "#/components/schemas/datetime"
      - name: replace
        in: query
        description: Replace any existing bookmark with this URL. Default is yes.
          If set to no, will throw an error if bookmark exists
        schema:
          "$ref": "#/components/schemas/yes_no"
      - name: shared
        in: query
        description: Make bookmark public. Default is 'yes' unless user has enabled
          the 'save all bookmarks as private' user setting, in which case default
          is 'no'
        schema:
          "$ref": "#/components/schemas/yes_no"
      - name: toread
        in: query
        description: Marks the bookmark as unread. Default is 'no'
        schema:
          "$ref": "#/components/schemas/yes_no"
      responses:
        '200':
          "$ref": "#/components/responses/Done"
  "/posts/delete":
    get:
      tags:
      - posts
      summary: Delete a bookmark.
      operationId: deletePost
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      - "$ref": "#/components/parameters/urlParam"
      responses:
        '200':
          "$ref": "#/components/responses/Done"
  "/tags/get":
    get:
      tags:
      - tags
      summary: Returns a full list of the user's tags along with the number of times
        they were used.
      operationId: getTags
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      responses:
        '200':
          description: All tags
          content:
            application/xml:
              schema:
                xml:
                  name: tags
                properties:
                  tag:
                    type: array
                    items:
                      type: object
                      properties:
                        count:
                          xml:
                            attribute: true
                          type: integer
                          example: 5
                        tag:
                          xml:
                            attribute: true
                          example: foo
  "/tags/delete":
    get:
      tags:
      - tags
      summary: Delete an existing tag.
      operationId: deleteTag
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      - name: tag
        in: query
        required: true
        schema:
          "$ref": "#/components/schemas/tag"
      responses:
        '200':
          "$ref": "#/components/responses/Done"
  "/tags/rename":
    get:
      tags:
      - tags
      summary: Rename an tag, or fold it in to an existing tag.
      operationId: renameTag
      parameters:
      - "$ref": "#/components/parameters/formatParam"
      - name: old
        in: query
        description: 'note: match is not case sensitive'
        required: true
        schema:
          "$ref": "#/components/schemas/tag"
      - name: new
        in: query
        description: if empty, nothing will happen
        required: true
        schema:
          "$ref": "#/components/schemas/tag"
      responses:
        '200':
          "$ref": "#/components/responses/Done"
components:
  parameters:
    formatParam:
      name: format
      in: query
      schema:
        "$ref": "#/components/schemas/format"
    urlParam:
      name: url
      in: query
      required: true
      schema:
        "$ref": "#/components/schemas/URL"
    urlParamOpt:
      name: url
      in: query
      schema:
        "$ref": "#/components/schemas/URL"
  responses:
    Done:
      description: A normal response
      content:
        application/xml:
          schema:
            xml:
              name: result
            properties:
              code:
                example:
                - done
                - something went wrong
                xml:
                  attribute: true
  schemas:
    tag:
      type: string
      description: up to 255 characters. May not contain commas or whitespace. Please
        be aware that tags beginning with a period are treated as private and trigger
        special private tag semantics.
    URL:
      type: string
      description: as defined by RFC 3986. Allowed schemes are http, https, javascript,
        mailto, ftp and file. The Safari-specific feed scheme is allowed but will
        be treated as a synonym for http.
    title:
      type: string
      description: up to 255 characters long
    text:
      type: string
      description: up to 65536 characters long. Any URLs will be auto-linkified when
        displayed.
    datetime:
      type: string
      description: 'UTC timestamp in this format: 2010-12-11T19:48:02Z. Valid date
        range is Jan 1, 1 AD to January 1, 2100 (but see note below about future timestamps).'
      example: '2010-12-11T19:48:02Z'
    date:
      type: string
      description: 'UTC date in this format: 2010-12-11. Same range as datetime above'
      example: '2010-12-11'
    yes_no:
      type: string
      description: the literal string 'yes' or 'no'
      example: 'no'
    md5:
      type: string
      description: 32 character hexadecimal MD5 hash
    integer:
      type: integer
      description: integer in the range 0..2^32
    format:
      type: string
      description: the literal string 'json' or 'xml'
      example: json
      default: xml
    posts:
      type: array
      properties:
        dt:
          type: string
          xml:
            attribute: true
          example: '2010-12-11T19:48:02Z'
        tag:
          type: string
          xml:
            attribute: true
          example: webdev
        user:
          type: string
          xml:
            attribute: true
          example: user
      items:
        type: object
        xml:
          name: post
        properties:
          href:
            type: string
            xml:
              attribute: true
            example: http://www.howtocreate.co.uk/tutorials/texterise.php?dom=1
          description:
            type: string
            xml:
              attribute: true
            example: JavaScript DOM reference
          extended:
            type: string
            xml:
              attribute: true
            example: dom reference
          hash:
            type: string
            xml:
              attribute: true
            example: c0238dc0c44f07daedd9a1fd9bbdeebd
          meta:
            type: string
            xml:
              attribute: true
            example: 92959a96fd69146c5fe7cbde6e5720f2
          others:
            type: string
            xml:
              attribute: true
            example: 55
          tag:
            type: string
            xml:
              attribute: true
            example: dom javascript webdev
          time:
            type: string
            xml:
              attribute: true
            example: '2005-11-28T05:26:09.000Z'
  securitySchemes:
    BasicAuth:
      type: http
      scheme: basic
    AuthToken:
      type: apiKey
      in: query
      name: auth_token
      description: |-
        An authentication token is a short opaque identifier in the form
        'username:TOKEN'.

        Users can find their API token on their settings page. They can request
        a new token at any time; this will invalidate their previous API token.

        Any third-party sites making API requests on behalf of Pinboard users
        from an outside server MUST use this authentication method instead of
        storing the user's password. Violators will be blocked from using the
        API.
security:
- AuthToken: []
- BasicAuth: []
tags:
- name: api
  externalDocs:
    url: https://pinboard.in/api
    description: The original Pinboard API

D patches/sebsauvage/Shaarli/archive/master/001.patch => patches/sebsauvage/Shaarli/archive/master/001.patch +0 -66
@@ 1,66 0,0 @@
diff --git a/index.php b/index.php
index c102e42..bafec87 100644
--- a/index.php
+++ b/index.php
@@ -43,7 +43,7 @@ define('WEB_PATH', substr($_SERVER["REQUEST_URI"], 0, 1+strrpos($_SERVER["REQUES
 // Force cookie path (but do not change lifetime)
 $cookie=session_get_cookie_params();
 $cookiedir = ''; if(dirname($_SERVER['SCRIPT_NAME'])!='/') $cookiedir=dirname($_SERVER["SCRIPT_NAME"]).'/';
-session_set_cookie_params($cookie['lifetime'],$cookiedir,$_SERVER['HTTP_HOST']); // Set default cookie expiration and path.
+session_set_cookie_params($cookie['lifetime'],$cookiedir,$_SERVER['SERVER_NAME']); // Set default cookie expiration and path.
 
 // Set session parameters on server side.
 define('INACTIVITY_TIMEOUT',3600); // (in seconds). If the user does not access any page within this time, his/her session is considered expired.
@@ -413,14 +413,14 @@ if (isset($_POST['login']))
             $_SESSION['expires_on']=time()+$_SESSION['longlastingsession'];  // Set session expiration on server-side.
 
             $cookiedir = ''; if(dirname($_SERVER['SCRIPT_NAME'])!='/') $cookiedir=dirname($_SERVER["SCRIPT_NAME"]).'/';
-            session_set_cookie_params($_SESSION['longlastingsession'],$cookiedir,$_SERVER['HTTP_HOST']); // Set session cookie expiration on client side
+            session_set_cookie_params($_SESSION['longlastingsession'],$cookiedir,$_SERVER['SERVER_NAME']); // Set session cookie expiration on client side
             // Note: Never forget the trailing slash on the cookie path !
             session_regenerate_id(true);  // Send cookie with new expiration date to browser.
         }
         else // Standard session expiration (=when browser closes)
         {
             $cookiedir = ''; if(dirname($_SERVER['SCRIPT_NAME'])!='/') $cookiedir=dirname($_SERVER["SCRIPT_NAME"]).'/';
-            session_set_cookie_params(0,$cookiedir,$_SERVER['HTTP_HOST']); // 0 means "When browser closes"
+            session_set_cookie_params(0,$cookiedir,$_SERVER['SERVER_NAME']); // 0 means "When browser closes"
             session_regenerate_id(true);
         }
         // Optional redirect after login:
@@ -452,7 +452,7 @@ function serverUrl()
 {
     $https = (!empty($_SERVER['HTTPS']) && (strtolower($_SERVER['HTTPS'])=='on')) || $_SERVER["SERVER_PORT"]=='443'; // HTTPS detection.
     $serverport = ($_SERVER["SERVER_PORT"]=='80' || ($https && $_SERVER["SERVER_PORT"]=='443') ? '' : ':'.$_SERVER["SERVER_PORT"]);
-    return 'http'.($https?'s':'').'://'.$_SERVER['HTTP_HOST'].$serverport;
+    return 'http'.($https?'s':'').'://'.$_SERVER['SERVER_NAME'].$serverport;
 }
 
 // Returns the absolute URL of current script, without the query.
@@ -1302,7 +1302,7 @@ function renderPage()
         if (is_numeric($_GET['linksperpage'])) { $_SESSION['LINKS_PER_PAGE']=abs(intval($_GET['linksperpage'])); }
         // Make sure the referer is from Shaarli itself.
         $referer = '?';
-        if (!empty($_SERVER['HTTP_REFERER']) && strcmp(parse_url($_SERVER['HTTP_REFERER'],PHP_URL_HOST),$_SERVER['HTTP_HOST'])==0)
+        if (!empty($_SERVER['HTTP_REFERER']) && strcmp(parse_url($_SERVER['HTTP_REFERER'],PHP_URL_HOST),$_SERVER['SERVER_NAME'])==0)
             $referer = $_SERVER['HTTP_REFERER'];
         header('Location: '.$referer);
         exit;
@@ -1321,7 +1321,7 @@ function renderPage()
         }
         // Make sure the referer is from Shaarli itself.
         $referer = '?';
-        if (!empty($_SERVER['HTTP_REFERER']) && strcmp(parse_url($_SERVER['HTTP_REFERER'],PHP_URL_HOST),$_SERVER['HTTP_HOST'])==0)
+        if (!empty($_SERVER['HTTP_REFERER']) && strcmp(parse_url($_SERVER['HTTP_REFERER'],PHP_URL_HOST),$_SERVER['SERVER_NAME'])==0)
             $referer = $_SERVER['HTTP_REFERER'];
         header('Location: '.$referer);
         exit;
@@ -2059,7 +2059,7 @@ function lazyThumbnail($url,$href=false)
 function install()
 {
     // On free.fr host, make sure the /sessions directory exists, otherwise login will not work.
-    if (endsWith($_SERVER['HTTP_HOST'],'.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions',0705);
+    if (endsWith($_SERVER['SERVER_NAME'],'.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions',0705);
 
 
     // This part makes sure sessions works correctly.

M pinboard.go => pinboard.go +336 -284
@@ 1,5 1,5 @@
//
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/Shaarli-API-test
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by


@@ 18,7 18,9 @@
package main

import (
	"bytes"
	"encoding/xml"
	"fmt"
	"io"
	"log"
	"net/http"


@@ 27,6 29,7 @@ import (
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"



@@ 51,119 54,98 @@ func trace(name string) (string, time.Time) { return name, time.Now() }
func un(name string, start time.Time)       { log.Printf("%s took %s", name, time.Since(start)) }

func main() {
	if cli() {
		return
	}

	if true {
		// lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
		log.SetOutput(os.Stderr)
	} else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
	}

	if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
	// - http.StripPrefix (and just keep PATH_INFO as Request.URL.path)
	// - route
	// - authenticate
	// - extract parameters
	// - call api backend method
	// - build response

	h := handleMux(os.Getenv("PATH_INFO"))
	if err := cgi.Serve(http.TimeoutHandler(h, 5*time.Second, "🐌")); err != nil {
		log.Fatal(err)
	}
}

// https://pinboard.in/api
func handleMux(w http.ResponseWriter, r *http.Request) {
	raw := func(s ...string) {
		for _, txt := range s {
			io.WriteString(w, txt)
		}
/// $ ./pinboard4shaarli.cgi --help | -h | -?
/// $ ./pinboard4shaarli.cgi https://demo.shaarli.org/pinboard4shaarli.cgi/v1/about
/// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/get?url=http://m.heise.de/12'
/// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/add?url=http://m.heise.de/12&description=foo'
/// todo
/// $ ./pinboard4shaarli.cgi https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/user/api_token
/// $ ./pinboard4shaarli.cgi --data-urlencode auth_token=uid:XYZUUU --data-urlencode url=https://m.heise.de/foo https://demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/get
///
func cli() bool {
	// test if we're running cli
	if len(os.Args) == 1 {
		return false
	}
	elmS := func(e string, close bool, atts ...string) {
		raw("<", e)
		for i, v := range atts {
			if i%2 == 0 {
				raw(" ", v, "=")
			} else {
				raw("'")
				xml.EscapeText(w, []byte(v))
				raw("'")
			}
		}
		if close {
			raw(" /")

	for i, a := range os.Args[2:] {
		fmt.Fprintf(os.Stderr, "  %d: %s\n", i, a)
	}

	// todo?: add parameters

	if req, err := http.NewRequest(http.MethodGet, os.Args[1], nil); err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", err.Error())
	} else {
		usr := req.URL.User
		if pwd, isset := usr.Password(); isset {
			req.SetBasicAuth(usr.Username(), pwd)
		}
		raw(">", "\n")
		bin := filepath.Base(os.Args[0])
		str := req.URL.Path
		idx := strings.LastIndex(str, bin)
		pi := str[idx+len(bin):]
		handleMux(pi)(reqWri{r: req, f: os.Stderr, h: http.Header{}}, req)
	}
	elmE := func(e string) { raw("</", e, ">", "\n") }

	defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
	path_info := os.Getenv("PATH_INFO")
	base := *r.URL
	base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")
	return true
}

	w.Header().Set(http.CanonicalHeaderKey("X-Powered-By"), strings.Join([]string{"https://code.mro.name/mro/Shaarli-API-test", "#", version, "+", GitSHA1}, ""))
	w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/xml; charset=utf-8")
type reqWri struct {
	r *http.Request
	f io.Writer
	h http.Header
}

	// https://stackoverflow.com/a/18414432
	options := cookiejar.Options{
		PublicSuffixList: publicsuffix.List,
	}
	jar, err := cookiejar.New(&options)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
func (w reqWri) Header() http.Header {
	return w.h
}
func (w reqWri) Write(b []byte) (int, error) {
	return w.f.Write(b)
}
func (w reqWri) WriteHeader(statusCode int) {
	const LF = "\r\n"
	fmt.Fprintf(w.f, "%s %d %s"+LF, w.r.Proto, statusCode, http.StatusText(statusCode))
	for k, v := range w.Header() {
		fmt.Fprintf(w.f, "%s: %s"+LF, k, strings.Join(v, " "))
	}
	client := http.Client{Jar: jar}

	switch path_info {
	case "",
		"/about":
		base := *r.URL
		base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "about") + "/"
		http.Redirect(w, r, base.Path, http.StatusFound)
	fmt.Fprintf(w.f, LF)
}

		return
	case "/about/":
		// w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "application/rdf+xml")
		raw(`<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns="http://usefulinc.com/ns/doap#">
  <Project>
    <name xml:lang="en">🛠 Shaarli Pinboard API</name>
    <short-description xml:lang="en">subset conforming https://pinboard.in/api/</short-description>
    <implements rdf:resource="https://pinboard.in/api/"/>
    <platform rdf:resource="https://sebsauvage.net/wiki/doku.php?id=php:shaarli"/>
    <homepage rdf:resource="https://code.mro.name/mro/Shaarli-API-test/"/>
    <bug-database rdf:resource="https://code.mro.name/mro/Shaarli-API-test/issues"/>
    <wiki rdf:resource="https://code.mro.name/mro/Shaarli-API-test/wiki"/>
    <license rdf:resource="https://code.mro.name/mro/Shaarli-API-test/src/master/LICENSE"/>
    <maintainer rdf:resource="http://mro.name/~me"/>
    <programming-language>golang</programming-language>
    <category>microblogging</category>
    <category>shaarli</category>
    <category>nodb</category>
    <category>api</category>
    <category>pinboard</category>
    <category>delicious</category>
    <category>cgi</category>
    <repository>
      <GitRepository>
        <browse rdf:resource="https://code.mro.name/mro/Shaarli-API-test"/>
        <location rdf:resource="https://code.mro.name/mro/Shaarli-API-test.git"/>
      </GitRepository>
    </repository>
    <release>
      <Version>
        <name>`, version, "+", GitSHA1, `</name>
        <revision>`, GitSHA1, `</revision>
        <description>…</description>
      </Version>
    </release>
  </Project>
</rdf:RDF>`)
// https://pinboard.in/api
func handleMux(path_info string) http.HandlerFunc {
	agent := strings.Join([]string{"https://code.mro.name/mro/pinboard4shaarli", "#", version, "+", GitSHA1}, "")
	// https://stackoverflow.com/a/18414432
	options := cookiejar.Options{PublicSuffixList: publicsuffix.List}
	
	return func(w http.ResponseWriter, r *http.Request) {
		defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))

		return
	case "/v1/posts/add":
		// extract parameters
		// agent := r.Header.Get("User-Agent")
		shared := true

		uid, pwd, ok := r.BasicAuth()
		if !ok {
			http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
			return
		}
		w.Header().Set(http.CanonicalHeaderKey("X-Powered-By"), agent)
		w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/xml; charset=utf-8")

		if http.MethodGet != r.Method {
			w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)


@@ 171,151 153,173 @@ func handleMux(w http.ResponseWriter, r *http.Request) {
			return
		}

		params := r.URL.Query()
		if 1 != len(params["url"]) {
			http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
			return
		}
		p_url := params["url"][0]

		if 1 != len(params["description"]) {
			http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
		jar, err := cookiejar.New(&options)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		p_description := params["description"][0]
		client := http.Client{Jar: jar, Timeout: 2 * time.Second}

		p_extended := ""
		if 1 == len(params["extended"]) {
			p_extended = params["extended"][0]
		}

		p_tags := ""
		if 1 == len(params["tags"]) {
			p_tags = params["tags"][0]
		asset := func(name, mime string) {
			w.Header().Set(http.CanonicalHeaderKey("Content-Type"), mime)
			if b, err := Asset(name); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			} else {
				w.Write(b)
			}
		}

		v := url.Values{}
		v.Set("post", p_url)
		v.Set("title", p_description)
		base.RawQuery = v.Encode()
		base := *r.URL
		base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")

		resp, err := client.Get(base.String())
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		formLogi, err := formValuesFromReader(resp.Body, "loginform")
		resp.Body.Close()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		switch path_info {
		case "":
			http.Redirect(w, r, "about", http.StatusFound)
			return
		}

		formLogi.Set("login", uid)
		formLogi.Set("password", pwd)
		resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
		case "/about":
			asset("doap.rdf", "application/rdf+xml")
			return
		}

		formLink, err := formValuesFromReader(resp.Body, "linkform")
		resp.Body.Close()
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
		case "/v1":
			http.Redirect(w, r, "v1/openapi.yaml", http.StatusFound)
			return
		}
		// if we do not have a linkform, auth must have failed.
		if 0 == len(formLink) {
			http.Error(w, "Authentication failed", http.StatusForbidden)
		case "/v1/openapi.yaml":
			asset("openapi.yaml", "text/x-yaml; charset=utf-8")
			return
		}

		// formLink.Set("lf_linkdate", ShaarliDate)
		// formLink.Set("lf_url", p_url)
		// formLink.Set("lf_title", p_description)
		formLink.Set("lf_description", p_extended)
		formLink.Set("lf_tags", p_tags)
		if shared {
			formLink.Del("lf_private")
		} else {
			formLink.Set("lf_private", "lf_private")
		}

		resp, err = client.PostForm(resp.Request.URL.String(), formLink)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		resp.Body.Close()
			// now comes the /real/ API
		case
			"/v1/posts/get":
			// pretend to add, but don't actually do it, but return the form preset values.
			uid, pwd, ok := r.BasicAuth()
			if !ok {
				http.Error(w, "Basic Pre-Authentication required.", http.StatusForbidden)
				return
			}

		raw(xml.Header)
		elmS("result", true,
			"code", "done")
			params := r.URL.Query()
			if 1 != len(params["url"]) {
				http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
				return
			}
			p_url := params["url"][0]

			/*
				if 1 != len(params["description"]) {
					http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
					return
				}
				p_description := params["description"][0]

				p_extended := ""
				if 1 == len(params["extended"]) {
					p_extended = params["extended"][0]
				}

				p_tags := ""
				if 1 == len(params["tags"]) {
					p_tags = params["tags"][0]
				}
			*/

			v := url.Values{}
			v.Set("post", p_url)
			base.RawQuery = v.Encode()

			req, err := http.NewRequest(http.MethodGet, base.String(), nil)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
			resp, err := client.Do(req)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			formLogi, err := formValuesFromReader(resp.Body, "loginform")
			resp.Body.Close()
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}

		return
	case "/v1/posts/delete":
		_, _, ok := r.BasicAuth()
		if !ok {
			http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
			return
		}
			formLogi.Set("login", uid)
			formLogi.Set("password", pwd)

		if http.MethodGet != r.Method {
			w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
			http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
			return
		}
			req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLogi.Encode())))
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
			req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
			resp, err = client.Do(req)
			// resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}

		params := r.URL.Query()
		if 1 != len(params["url"]) {
			http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
			return
		}
		// p_url := params["url"][0]
			formLink, err := formValuesFromReader(resp.Body, "linkform")
			resp.Body.Close()
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			// if we do not have a linkform, auth must have failed.
			if 0 == len(formLink) {
				http.Error(w, "Authentication failed", http.StatusForbidden)
				return
			}

		elmS("result", true,
			"code", "not implemented yet")
		return
	case "/v1/posts/update":
		_, _, ok := r.BasicAuth()
		if !ok {
			http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
			return
		}
			fv := func(s string) string { return formLink.Get(s) }

		if http.MethodGet != r.Method {
			w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
			http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
			return
		}
			tim, err := time.ParseInLocation(ShaarliDate, fv("lf_linkdate"), time.Local) // can we do any better?
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}

		raw(xml.Header)
		elmS("update", true,
			"time", "2011-03-24T19:02:07Z")
		return
	case "/v1/posts/get":
		// pretend to add, but don't actually do it, but return the form preset values.
		uid, pwd, ok := r.BasicAuth()
		if !ok {
			http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
			return
		}
			w.Write([]byte(xml.Header))
			pp := Posts{
				User: uid,
				Dt:   tim.Format(IsoDate),
				Tag:  fv("lf_tags"),
				Posts: []Post{
					Post{
						Href:        fv("lf_url"),
						Hash:        fv("lf_linkdate"),
						Description: fv("lf_title"),
						Extended:    fv("lf_description"),
						Tag:         fv("lf_tags"),
						Time:        tim.Format(time.RFC3339),
					},
				},
			}
			enc := xml.NewEncoder(w)
			enc.Encode(pp)
			enc.Flush()

		if http.MethodGet != r.Method {
			w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
			http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
			return
		}
		case
			"/v1/posts/add":
			// extract parameters
			// agent := r.Header.Get("User-Agent")
			shared := true

			uid, pwd, ok := r.BasicAuth()
			if !ok {
				http.Error(w, "Basic Pre-Authentication required.", http.StatusForbidden)
				return
			}

		params := r.URL.Query()
		if 1 != len(params["url"]) {
			http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
			return
		}
		p_url := params["url"][0]
			params := r.URL.Query()
			if 1 != len(params["url"]) {
				http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
				return
			}
			p_url := params["url"][0]

		/*
			if 1 != len(params["description"]) {
				http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
				return


@@ 331,82 335,130 @@ func handleMux(w http.ResponseWriter, r *http.Request) {
			if 1 == len(params["tags"]) {
				p_tags = params["tags"][0]
			}
		*/

		v := url.Values{}
		v.Set("post", p_url)
		base.RawQuery = v.Encode()
			v := url.Values{}
			v.Set("post", p_url)
			v.Set("title", p_description)
			base.RawQuery = v.Encode()

		resp, err := client.Get(base.String())
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		formLogi, err := formValuesFromReader(resp.Body, "loginform")
		resp.Body.Close()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
			req, err := http.NewRequest(http.MethodGet, base.String(), nil)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
			resp, err := client.Do(req)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			formLogi, err := formValuesFromReader(resp.Body, "loginform")
			resp.Body.Close()
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}

		formLogi.Set("login", uid)
		formLogi.Set("password", pwd)
		resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
			formLogi.Set("login", uid)
			formLogi.Set("password", pwd)

		formLink, err := formValuesFromReader(resp.Body, "linkform")
		resp.Body.Close()
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		// if we do not have a linkform, auth must have failed.
		if 0 == len(formLink) {
			http.Error(w, "Authentication failed", http.StatusForbidden)
			return
		}
			req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLogi.Encode())))
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
			req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
			resp, err = client.Do(req)
			// resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}

		fv := func(s string) string { return formLink.Get(s) }
			formLink, err := formValuesFromReader(resp.Body, "linkform")
			resp.Body.Close()
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			// if we do not have a linkform, auth must have failed.
			if 0 == len(formLink) {
				http.Error(w, "Authentication failed", http.StatusForbidden)
				return
			}

			// formLink.Set("lf_linkdate", ShaarliDate)
			// formLink.Set("lf_url", p_url)
			// formLink.Set("lf_title", p_description)
			formLink.Set("lf_description", p_extended)
			formLink.Set("lf_tags", p_tags)
			if shared {
				formLink.Del("lf_private")
			} else {
				formLink.Set("lf_private", "lf_private")
			}

			req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLink.Encode())))
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
			req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
			resp, err = client.Do(req)
			// resp, err = client.PostForm(resp.Request.URL.String(), formLink)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadGateway)
				return
			}
			resp.Body.Close()

			w.Write([]byte(xml.Header))
			pp := Result{Code: "done"}
			enc := xml.NewEncoder(w)
			enc.Encode(pp)
			enc.Flush()

		tim, err := time.ParseInLocation(ShaarliDate, fv("lf_linkdate"), time.Local) // can we do any better?
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		case
			"/v1/posts/delete":
			_, _, ok := r.BasicAuth()
			if !ok {
				http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
				return
			}

		raw(xml.Header)
		elmS("posts", false,
			"user", uid,
			"dt", tim.Format(IsoDate),
			"tag", fv("lf_tags"))
		elmS("post", true,
			"href", fv("lf_url"),
			"hash", fv("lf_linkdate"),
			"description", fv("lf_title"),
			"extended", fv("lf_description"),
			"tag", fv("lf_tags"),
			"time", tim.Format(time.RFC3339),
			"others", "0")
		elmE("posts")
			params := r.URL.Query()
			if 1 != len(params["url"]) {
				http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
				return
			}
			// p_url := params["url"][0]

		return
	case "/v1/posts/recent",
		"/v1/posts/dates",
		"/v1/posts/suggest",
		"/v1/tags/get",
		"/v1/tags/delete",
		"/v1/tags/rename",
		"/v1/user/secret",
		"/v1/user/api_token",
		"/v1/notes/list",
		"/v1/notes/ID":
		http.Error(w, "Not Implemented", http.StatusNotImplemented)
		return
			w.Write([]byte(xml.Header))
			pp := Result{Code: "not implemented yet"}
			enc := xml.NewEncoder(w)
			enc.Encode(pp)
			enc.Flush()
			return
		case
			"/v1/notes/ID",
			"/v1/notes/list",
			"/v1/posts/dates",
			"/v1/posts/suggest",
			"/v1/posts/update",
			"/v1/tags/delete",
			"/v1/tags/get",
			"/v1/tags/rename",
			"/v1/user/api_token",
			"/v1/user/secret",
			"/v1/posts/recent":
			http.Error(w, "Not Implemented", http.StatusNotImplemented)
			return
		}
		http.NotFound(w, r)
	}
	http.NotFound(w, r)
}

func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {

M pinboard_test.go => pinboard_test.go +75 -1
@@ 1,5 1,5 @@
//
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/Shaarli-API-test
// Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by


@@ 18,15 18,77 @@
package main

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"net/http"
	//	"net/http/cgi"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strings"

	"github.com/stretchr/testify/assert"
	"net/http/httptest"
	"testing"
)

func TestString(t *testing.T) {
	t.Parallel()
	const s = "abcde"
	assert.Equal(t, "de", s[len(s)-2:], "ach")
}

func TestPath(t *testing.T) {
	t.Parallel()
	path_info := "pinboard4shaarli.cgi"
	base, _ := url.Parse("https://demo.shaarli.org/pinboard4shaarli.cgi/v1/about/")
	base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")
	assert.Equal(t, "/index.php", base.Path, "ach")
	assert.Equal(t, "https://demo.shaarli.org/index.php", base.String(), "ach")

	cgi := filepath.Base("../uhu/pinboard4shaarli.cgi")
	str := "https://demo.shaarli.org/pinboard4shaarli.cgi/v1/about/"
	idx := strings.LastIndex(str, cgi)
	assert.Equal(t, "/v1/about/", str[idx+len(cgi):], "wowo")
}

func TestCgi(t *testing.T) {
	t.Parallel()

	// rq := httptest.NewRequest(http.MethodGet, "/", nil)
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello, client, %s\n", r.URL.String())
	}))
	defer ts.Close()

	// assert.Equal(t, "", ts.Config, "wowo")
	// assert.Equal(t, "", ts.Config.Addr, "wowo")
	res, _ := http.Get(ts.URL + "/" + path.Join("pinboard4shaarli.cgi"))
	assert.Equal(t, http.StatusOK, res.StatusCode, "wowo")
	assert.Equal(t, int64(37), res.ContentLength, "wowo")
	assert.Equal(t, "text/plain; charset=utf-8", res.Header.Get("Content-Type"), "wowo")

	body, _ := ioutil.ReadAll(res.Body)
	res.Body.Close()
	assert.Equal(t, "Hello, client, /pinboard4shaarli.cgi\n", string(body), "wowo")
}

func TestBasicAuth(t *testing.T) {
	t.Parallel()
	url := "https://dema:demu@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/add?url=http://m.heise.de/12"
	r, err := http.NewRequest(http.MethodGet, url, nil)
	assert.Equal(t, nil, err, "wowo")
	usr := r.URL.User
	pwd, _ := usr.Password()
	r.SetBasicAuth(usr.Username(), pwd)
	uid, pwd, ok := r.BasicAuth()
	assert.Equal(t, true, ok, "wowo")
	assert.Equal(t, "dema", uid, "wowo")
	assert.Equal(t, "demu", pwd, "wowo")
}

func TestURL(t *testing.T) {
	t.Parallel()



@@ 48,6 110,18 @@ func TestURL(t *testing.T) {
	assert.Equal(t, "https://l.mro.name/pinboard.cgi/../index.php?post=uhu", base.String(), "ach")
}

func TestForm(t *testing.T) {
	t.Parallel()

	f := url.Values{}
	f.Set("login", "u i d")
	f.Set("password", "p & =d")
	r := bytes.NewReader([]byte(f.Encode()))
	b, err := ioutil.ReadAll(r)
	assert.Nil(t, err, "zzz")
	assert.Equal(t, "login=u+i+d&password=p+%26+%3Dd", string(b), "zzz")
}

func TestFormValuesFromHtml(t *testing.T) {
	file, err := os.Open("testdata/v0.10.2/login.html") // curl --location --output testdata/login.html 'https://demo.shaarli.org/?post=https://demo.mro.name/shaarligo'
	assert.Nil(t, err, "soso")

A post.msc => post.msc +36 -0
@@ 0,0 1,36 @@
# 
# http://www.mcternan.me.uk/mscgen/
# mscgen -T png post.msc ; optipng -o 7 post.png
msc {
  hscale = "1";

  ios [label="Client"],
  cgi [label="pinboard4shaarli.cgi"],
  php [label="shaarli.php"],
  www [label="www.example.com"];

  ios -> cgi [ label = "GET get?url=..." ];
  cgi -> php [ label = "GET post=..." ];
  cgi << php [ label = "loginform"];
  cgi -> php [ label = "POST login" ];
  cgi << php [ label = "301" ];
  cgi -> php [ label = "GET post=..." ];
  php -> www [ label = "GET" ];
  php << www [ label = "<title>" ];
  cgi << php [ label = "linkform" ];
  ios << cgi [ label = "cookie+token?" ];
  ...;
  ---  [ label = "manually edit description & tags" ];
  ios -> cgi [ label = "GET add?url=..." ];
  cgi -> php [ label = "GET post=..." ];
  cgi << php [ label = "loginform"];
  cgi -> php [ label = "POST login" ];
  cgi << php [ label = "301" ];
  cgi -> php [ label = "GET post=..." ];
  php -> www [ label = "GET" ];
  php << www [ label = "<title>" ];
  cgi << php [ label = "linkform"];
  cgi -> php [ label = "POST post=..." ];
  cgi << php;
  ios << cgi;
}

A post.png => post.png +0 -0
D scripts/assert.sh => scripts/assert.sh +0 -61
@@ 1,61 0,0 @@
#
#  Copyright (c) 2015 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

# insipired by https://github.com/lehmannro/assert.sh but much more primitive.

# terminal colors (require bash)
# http://www.tldp.org/HOWTO/Bash-Prompt-HOWTO/x329.html
# http://wiki.bash-hackers.org/scripting/terminalcodes
FGC_NONE="\033[0m"
FGC_GRAY="\033[1;30m"
FGC_RED="\033[1;31m"
FGC_GREEN="\033[1;32m"
FGC_YELLOW="\033[1;33m"
FGC_BLUE="\033[1;34m"
FGC_PURPLE="\033[1;35m"
FGC_CYAN="\033[1;36m"
FGC_WHITE="\033[1;37m"
BGC_GRAY="\033[7;30m"
BGC_RED="\033[7;31m"
BGC_GREEN="\033[7;32m"
BGC_YELLOW="\033[7;33m"
BGC_BLUE="\033[7;34m"
BGC_PURPLE="\033[7;35m"
BGC_CYAN="\033[7;36m"
BGC_WHITE="\033[7;37m"

assert_fail() {
  local CODE="${1}" MESSAGE="${2}"
  echo -e "${BGC_RED}assert_fail (${CODE}): ${MESSAGE}${FGC_NONE}"
  exit $1
}

assert_equal() {
  local EXPECTED="${1}" ACTUAL="${2}" CODE="${3}" MESSAGE="${4}"
  if [ ! "${EXPECTED}" = "${ACTUAL}" ] ; then
    echo -e "${BGC_RED}assert_equal${FGC_NONE} (${CODE}): ${MESSAGE}  expected: \"${FGC_GREEN}${EXPECTED}${FGC_NONE}\" != actual: \"${FGC_RED}${ACTUAL}${FGC_NONE}\""
    exit $3
  fi
}

assert_fgrep() {
  local PATTERN="${1}" ACTUAL="${2}" CODE="${3}" MESSAGE="${4}"
  echo "${ACTUAL}" | fgrep "${PATTERN}" 1> /dev/null 2>&1 || { \
    echo -e "${BGC_RED}assert_equal${FGC_NONE} (${CODE}): ${MESSAGE}  pattern: \"${FGC_GREEN}${PATTERN}${FGC_NONE}\" != actual: \"${FGC_RED}${ACTUAL}${FGC_NONE}\"" \
    && exit $3 ; \
  }
}

D scripts/categories.rng => scripts/categories.rng +0 -139
@@ 1,139 0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
  https://tools.ietf.org/html/rfc5023#appendix-B
  not: https://web.archive.org/web/20130716150512/http://www.atomenabled.org:80/developers/protocol/atom-protocol-spec.php#rfc.section.8.3.3
-->
<!-- -*- rnc -*- # RELAX NG Compact Syntax Grammar for the Atom Protocol -->
<grammar xmlns:app="http://www.w3.org/2007/app" ns="http://www.w3.org/2005/Atom" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:atom="http://www.w3.org/2005/Atom" xmlns="http://relaxng.org/ns/structure/1.0" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
  <start>
    <ref name="appCategories"/>
  </start>
  <define name="atomCommonAttributes">
    <optional>
      <attribute name="xml:base">
        <ref name="atomURI"/>
      </attribute>
    </optional>
    <optional>
      <attribute name="xml:lang">
        <ref name="atomLanguageTag"/>
      </attribute>
    </optional>
    <zeroOrMore>
      <ref name="undefinedAttribute"/>
    </zeroOrMore>
  </define>
  <define name="undefinedAttribute">
    <attribute>
      <anyName>
        <except>
          <name>xml:base</name>
          <name>xml:lang</name>
          <nsName ns=""/>
        </except>
      </anyName>
    </attribute>
  </define>
  <define name="atomURI">
    <text/>
  </define>
  <define name="atomLanguageTag">
    <data type="string">
      <param name="pattern">([A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*)?</param>
    </data>
  </define>
  <define name="atomCategory">
    <element name="atom:category">
      <ref name="atomCommonAttributes"/>
      <attribute name="term"/>
      <optional>
        <attribute name="scheme">
          <ref name="atomURI"/>
        </attribute>
      </optional>
      <optional>
        <attribute name="label"/>
      </optional>
      <ref name="undefinedContent"/>
    </element>
  </define>
  <define name="appInlineCategories">
    <element name="app:categories">
      <optional>
        <attribute name="fixed">
          <choice>
            <value>yes</value>
            <value>no</value>
          </choice>
        </attribute>
      </optional>
      <optional>
        <attribute name="scheme">
          <ref name="atomURI"/>
        </attribute>
      </optional>
      <group>
        <zeroOrMore>
          <ref name="atomCategory"/>
        </zeroOrMore>
        <ref name="undefinedContent"/>
      </group>
    </element>
  </define>
  <define name="appOutOfLineCategories">
    <element name="app:categories">
      <attribute name="href">
        <ref name="atomURI"/>
      </attribute>
      <empty/>
    </element>
  </define>
  <define name="appCategories">
    <choice>
      <ref name="appInlineCategories"/>
      <ref name="appOutOfLineCategories"/>
    </choice>
  </define>
  <!-- Extensibility -->
  <define name="undefinedContent">
    <zeroOrMore>
      <choice>
        <text/>
        <ref name="anyForeignElement"/>
      </choice>
    </zeroOrMore>
  </define>
  <define name="anyElement">
    <element>
      <anyName/>
      <zeroOrMore>
        <choice>
          <attribute>
            <anyName/>
          </attribute>
          <text/>
          <ref name="anyElement"/>
        </choice>
      </zeroOrMore>
    </element>
  </define>
  <define name="anyForeignElement">
    <element>
      <anyName>
        <except>
          <nsName/>
        </except>
      </anyName>
      <zeroOrMore>
        <choice>
          <attribute>
            <anyName/>
          </attribute>
          <text/>
          <ref name="anyElement"/>
        </choice>
      </zeroOrMore>
    </element>
  </define>
</grammar>
<!-- EOF -->

D scripts/download.sh => scripts/download.sh +0 -24
@@ 1,24 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/.." || exit 1

[ "${GITHUB}" != "" ] || { echo 'I need ${GITHUB}, e.g. shaarli/Shaarli/archive/v0.0.40beta' && exit 2; }

# Download the tarball...
GITHUB_SRC_URL="https://github.com/${GITHUB}.tar.gz"
curl --location --output source.tar.gz --url "${GITHUB_SRC_URL}" || { echo "ouch" && exit 3; }

D scripts/form2post.rb => scripts/form2post.rb +0 -23
@@ 1,23 0,0 @@
#!/usr/bin/env ruby
#
#  Copyright (c) 2015 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
require 'rexml/document'
require 'cgi'

REXML::Document.new($stdin).elements.each('//textarea | //input[@type = "text" or @type = "hidden"]') do |element|
  puts "#{CGI.escape(element.attributes['name'] || '')}=#{CGI.escape(element.attributes['value'] || '')}&"
end

D scripts/run-tests.sh => scripts/run-tests.sh +0 -126
@@ 1,126 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

# Check preliminaries
curl --version >/dev/null || { echo "I need curl." && exit 101 ; }
xmllint --version 2> /dev/null || { echo "I need xmllint." && exit 102 ; }
ruby --version > /dev/null || { echo "I need ruby." && exit 103 ; }

[ "${GITHUB_SRC_SUBDIR}" != "" ] || { echo 'I need ${GITHUB_SRC_SUBDIR}, e.g. Shaarli-*' && exit 2; }
[ "${BASE_URL}" != "" ] || { echo 'I need ${BASE_URL}, e.g. http://127.0.0.1:8000' && exit 2; }
[ "${USERNAME}" != "" ] || { echo 'I need ${USERNAME}, e.g. tast' && exit 2; }
[ "${PASSWORD}" != "" ] || { echo 'I need ${PASSWORD}, e.g. tust' && exit 2; }

cd "$(dirname "$0")/.." || exit 1
CWD="$(pwd)"
WORK_DIR="${CWD}/tmp"

# terminal colors (require bash)
# http://www.tldp.org/HOWTO/Bash-Prompt-HOWTO/x329.html
# http://wiki.bash-hackers.org/scripting/terminalcodes
FGC_NONE="\033[0m"
FGC_GRAY="\033[1;30m"
FGC_RED="\033[1;31m"
FGC_GREEN="\033[1;32m"
FGC_YELLOW="\033[1;33m"
FGC_BLUE="\033[1;34m"
FGC_PURPLE="\033[1;35m"
FGC_CYAN="\033[1;36m"
FGC_WHITE="\033[1;37m"
BGC_GRAY="\033[7;30m"
BGC_RED="\033[7;31m"
BGC_GREEN="\033[7;32m"
BGC_YELLOW="\033[7;33m"
BGC_BLUE="\033[7;34m"
BGC_PURPLE="\033[7;35m"
BGC_CYAN="\033[7;36m"
BGC_WHITE="\033[7;37m"

echo "\$ curl --version" ; curl --version

status_code=0
test_counter=1
echo "1..$(ls "${CWD}/tests"/test-*.sh | wc -l)"
for tst in "${CWD}/tests"/test-*.sh
do
  test_name="$(basename "${tst}")"
  echo -n "travis_fold:start:${test_name}\r"
  echo -n "# run ${test_counter} - ${test_name} "

  # prepare a clean test environment from scratch
  cd "${CWD}"
  rm -rf "${WORK_DIR}" && mkdir "${WORK_DIR}"
  cd "${WORK_DIR}"

  # ...and unpack into directory 'WebAppRoot'...
  tar -xzf "${CWD}/source.tar.gz" || { echo "ouch" && exit 1 ; }
  mv ${GITHUB_SRC_SUBDIR} "WebAppRoot"
  cp "${CWD}/pinboard.cgi" "WebAppRoot/"

  for patchfile in "${CWD}/patches/${GITHUB}"/*.patch
  do
    [ -r "${patchfile}" ] && patch -p1 -d "WebAppRoot" < "${patchfile}"
  done

  # https://github.com/shaarli/Shaarli/issues/613
  cd "WebAppRoot" && composer update --no-dev ; cd "${WORK_DIR}"

  # http://robbiemackay.com/2013/05/03/automating-behat-and-mink-tests-with-travis-ci/
  # webserver setup
  php -S 127.0.0.1:8000 -t "WebAppRoot" 1> php.stdout 2> php.stderr &
  sleep 1 # how could we get rid of this stupid sleep?

  ls -l "WebAppRoot/index.php" >/dev/null || { echo "ouch" && exit 2 ; }

  curl --silent --show-error \
    --url "${BASE_URL}" \
    --data-urlencode "setlogin=${USERNAME}" \
    --data-urlencode "setpassword=${PASSWORD}" \
    --data-urlencode "continent=Europe" \
    --data-urlencode "city=Brussels" \
    --data-urlencode "title=Review Shaarli" \
    --data-urlencode "Save=Save config" \
    --output /dev/null

  # execute each test
  /usr/bin/env bash "${tst}"
  code=$?

  killall php 1>/dev/null 2>&1
  wait
  cd "${WORK_DIR}"

  if [ ${code} -ne 0 ] ; then
    for f in curl.* WebAppRoot/data/log.txt WebAppRoot/data/ipbans.php WebAppRoot/data/config.php ; do
      printf " _\$_cat%-50s\n" "_${f}_" | tr ' _' '# '
      cat "${f}"
    done
    echo " "
  fi
  echo -n "travis_fold:end:${test_name}\r"

  if [ ${code} -eq 0 ] ; then
    echo "${FGC_GREEN}ok ${test_counter}${FGC_NONE} - ${test_name}"
  else
    echo "${FGC_RED}not ok ${test_counter}${FGC_NONE} - ${test_name} (code: ${code})"
    status_code=1
  fi
  test_counter=$((test_counter+1))
done

exit ${status_code}

D scripts/tagcloud-html2atom.xslt => scripts/tagcloud-html2atom.xslt +0 -47
@@ 1,47 0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--

 $ curl "https://links.mro.name/?do=tagcloud" | xsltproc - -html tagcloud-html2atom.xslt - | xmllint - -relaxng categories.rng -

 http://www.w3.org/TR/xslt/
 http://www.w3.org/TR/xpath/
 https://tools.ietf.org/html/rfc5023#appendix-B
-->
<xsl:stylesheet
  xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
  xmlns:dctype="http://purl.org/dc/dcmitype/"
  xmlns="http://www.w3.org/2005/Atom"
  xmlns:app="http://www.w3.org/2007/app"
  xmlns:foo="foo"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  exclude-result-prefixes="dctype rdf"
  version="1.0">

  <xsl:output method="xml" indent="yes"/>

  <xsl:template match="/html">
    <xsl:comment>
  https://tools.ietf.org/html/rfc5023#appendix-B
  http://atomenabled.org/developers/protocol/#appCategories1
  https://web.archive.org/web/20130716150512/http://www.atomenabled.org:80/developers/protocol/atom-protocol-spec.php#rfc.section.8.3.3
  https://web.archive.org/web/20130716150512/http://www.atomenabled.org:80/developers/protocol/atom-protocol-spec.php#schema
</xsl:comment>
    <app:categories scheme="?searchtags=">
      <xsl:variable name="traditional_count_prefix" select="1 = count(.//a[starts-with(@href, '?searchtags=')][1]/preceding-sibling::*[1])"/>

      <xsl:for-each select=".//a[starts-with(@href, '?searchtags=')]">
        <xsl:sort select="." data-type="text" order="ascending"/>

        <xsl:variable name="count">
          <xsl:choose>
            <xsl:when test="$traditional_count_prefix"><xsl:value-of select="preceding-sibling::*[1]"/></xsl:when>
            <xsl:otherwise><xsl:value-of select="following-sibling::*[1]"/></xsl:otherwise>
          </xsl:choose>
        </xsl:variable>

        <category term="{.}" foo:count="{$count}"/>
      </xsl:for-each>
    </app:categories>
  </xsl:template>

</xsl:stylesheet>

D tests/test-atom-empty.sh => tests/test-atom-empty.sh +0 -28
@@ 1,28 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${BASE_URL}" != "" ]         || assert_fail 1 "How strange, BASE_URL is unset."

curl --silent --show-error --output "curl.tmp.atom" "${BASE_URL}/?do=atom"
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' "curl.tmp.atom")
assert_equal 1 "${entries}" 28 "expected exactly one <entry>"

D tests/test-delete-all-ok.sh.xslt => tests/test-delete-all-ok.sh.xslt +0 -1
@@ 1,1 0,0 @@
test-delete-ok.sh.xslt
\ No newline at end of file

D tests/test-delete-ok.sh.xslt => tests/test-delete-ok.sh.xslt +0 -47
@@ 1,47 0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--

 Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.

 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.


 Find all 'delete_link' POST forms and list lf_linkdate and token.
 
 $ xsltproc - -html ../tests/test-delete-ok.sh.xslt curl.tmp.html

 http://www.w3.org/TR/xslt
-->
<xsl:stylesheet
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    exclude-result-prefixes="xsl"
    version="1.0">
  <xsl:output method="text"/>

  <xsl:template match="/">
    <xsl:for-each select="html/body//form[.//input/@name='delete_link' and .//input/@name='lf_linkdate' and .//input/@name='token']">
      <xsl:variable name="lf_linkdate" select=".//input[@name='lf_linkdate']/@value"/>
      <xsl:variable name="token" select=".//input[@name='token']/@value"/>
      <xsl:value-of select="$lf_linkdate"/><xsl:text> </xsl:text><xsl:value-of select="$token"/><xsl:text>
</xsl:text>
    </xsl:for-each>
    <xsl:for-each select="html/body//a[contains(@href, '?delete_link=') and contains(@href, '&amp;token=')]">
      <xsl:variable name="lf_linkdate" select="substring-before(substring-after(@href,'?delete_link='), '&amp;token=')"/>
      <xsl:variable name="token" select="substring-after(@href,'&amp;token=')"/>
      <xsl:value-of select="$lf_linkdate"/><xsl:text> </xsl:text><xsl:value-of select="$token"/><xsl:text>
</xsl:text>
    </xsl:for-each>
  </xsl:template>

</xsl:stylesheet>

D tests/test-index.sh => tests/test-index.sh +0 -28
@@ 1,28 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${BASE_URL}" != "" ]         || assert_fail 1 "How strange, BASE_URL is unset."

curl --silent "${BASE_URL}/" | xmllint --html --encode utf8 --format - 2>/dev/null >/dev/null

exit $?

D tests/test-login-ok.sh => tests/test-login-ok.sh +0 -90
@@ 1,90 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${USERNAME}" != "" ]         || assert_fail 1 "How strange, USERNAME is unset."
[ "${PASSWORD}" != "" ]         || assert_fail 2 "How strange, PASSWORD is unset."
[ "${BASE_URL}" != "" ]         || assert_fail 3 "How strange, BASE_URL is unset."

echo "###################################################"
echo "## non-logged-in GET /?post return: 302 "
http_code=$(curl --url "${BASE_URL}/?post" \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{http_code}' 2>/dev/null)
assert_equal 302 "${http_code}" 35 "login check."

echo "####################################################"
echo "## Step 1: fetch token to login "
echo "GET ${BASE_URL}?do=login"
rm curl.tmp.*
# http://unix.stackexchange.com/a/157219
LOCATION=$(curl --get --url "${BASE_URL}/?do=login" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 58 "error: '${errmsg}'"
TOKEN=$(xmllint --html --nowarning --xpath 'string(/html/body//form[@name="loginform"]//input[@name="token"]/@value)' curl.tmp.html)
# string(..) http://stackoverflow.com/a/18390404

# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 63 "found TOKEN=${TOKEN}"

echo "######################################################"
echo "## Step 2: follow the redirect, do the login and redirect to ?do=changepasswd "
echo "POST ${LOCATION}"
rm curl.tmp.*
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?do=changepasswd" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 80 "error during login"
assert_equal "${BASE_URL}/?do=changepasswd" "${LOCATION}" 81 "redirect after login"

# [ 1 -eq $(xmllint --html --nowarning --xpath "count(/html/body//a[@href = '?do=logout'])" curl.tmp.html 2>/dev/null) ] || assert_fail 13 "I expected a logout link."

# check presence of various mandatory form fields:
for field in oldpassword setpassword token
do
  assert_equal 1 $(xmllint --html --nowarning --xpath "count(/html/body//form[@name = 'changepasswordform']//input[@name='${field}'])" curl.tmp.html) 88 "expected to have a '${field}'"
done


echo "###################################################"
echo "## logged-in GET /?post return: 200 "
http_code=$(curl --url "${BASE_URL}/?post" \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{http_code}' 2>/dev/null)
assert_equal 200 "${http_code}" 90 "login check."

D tests/test-pinboard-info.sh => tests/test-pinboard-info.sh +0 -26
@@ 1,26 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2019-2019 Marcus Rohrmoser https://code.mro.name/mro/Shaarli-API-test. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${BASE_URL}" != "" ]         || assert_fail 1 "How strange, BASE_URL is unset."

curl --url "${BASE_URL}/pinboard.cgi/v1/info" 2>/dev/null | file -

D tests/test-post.sh => tests/test-post.sh +0 -119
@@ 1,119 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
ruby --version > /dev/null      || assert_fail 103 "I need ruby."
[ "${USERNAME}" != "" ]         || assert_fail 1 "How strange, USERNAME is unset."
[ "${PASSWORD}" != "" ]         || assert_fail 2 "How strange, PASSWORD is unset."
[ "${BASE_URL}" != "" ]         || assert_fail 3 "How strange, BASE_URL is unset."
assert_equal "" "$(echo "${BASE_URL}" | egrep -e "/$")" 28 "BASE_URL must be without trailing /"

echo "###################################################"
echo "## Non-logged-in Atom feed before adding a link (should have only the initial public default entry):"
curl --silent --show-error --output curl.tmp.atom "${BASE_URL}/?do=atom"
xmllint --encode utf8 --format curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal 1 "${entries}" 35 "Atom feed expected 1 = ${entries}"

echo "####################################################"
echo "## Step 1: fetch token to login and add a new link: "
echo "GET ${BASE_URL}?post=..."
rm curl.tmp.*
# http://unix.stackexchange.com/a/157219
LOCATION=$(curl --get --url "${BASE_URL}" \
  --data-urlencode "post=https://github.com/sebsauvage/Shaarli/commit/450342737ced8ef2864b4f83a4107a7fafcc4add" \
  --data-urlencode "title=Initial Commit to Shaarli on Github." \
  --data-urlencode "source=Source Text" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 52 "error: '${errmsg}'"
TOKEN=$(xmllint --html --nowarning --xpath 'string(/html/body//form[@name="loginform"]//input[@name="token"]/@value)' curl.tmp.html)
# string(..) http://stackoverflow.com/a/18390404

# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 57 "expected TOKEN of 40 characters, but found ${TOKEN} of $(printf "%s" ${TOKEN} | wc -c)"

echo "######################################################"
echo "## Step 2: follow the redirect, do the login and get the post form: "
echo "POST ${LOCATION}"
rm curl.tmp.*
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 73 "error: '${errmsg}'"
# check presence of various mandatory form fields:
for field in lf_url lf_title lf_linkdate lf_tags token
do
  assert_equal 1 $(xmllint --html --nowarning --xpath "count(/html/body//form[@name = 'linkform']//input[@name='${field}'])" curl.tmp.html) 77 "expected to have a '${field}'"
done
for field in lf_description
do
  assert_equal 1 $(xmllint --html --nowarning --xpath "count(/html/body//form[@name = 'linkform']//textarea[@name='${field}'])" curl.tmp.html) 81 "expected to have a '${field}'"
done

# turn form field data into curl post data file
xmllint --html --nowarning --xmlout curl.tmp.html | xmllint --xpath '/html/body//form[@name="linkform"]' - | /usr/bin/env ruby ../scripts/form2post.rb > curl.post

echo "######################################################"
echo "## Step 3: finally post the link: "
echo "POST ${LOCATION}"
rm curl.tmp.*
LOCATION=$(curl --url "${LOCATION}" \
  --data "@curl.post" \
  --data-urlencode "lf_linkdate=20130226_100941" \
  --data-urlencode "lf_source=$0" \
  --data-urlencode "lf_description=Must be older because http://sebsauvage.github.io/Shaarli/ mentions 'Copyright (c) 2011 Sébastien SAUVAGE (sebsauvage.net)'." \
  --data-urlencode "lf_tags=t1 t2" \
  --data-urlencode "save_edit=Save" \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{redirect_url}' 2>/dev/null)
# don't use --location and url_effective because this strips /?#... on curl 7.30.0 (x86_64-apple-darwin13.0)
echo "final ${LOCATION}"
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html 2>/dev/null)
assert_equal "" "${errmsg}" 106 "error: '${errmsg}'"
echo "${LOCATION}" | egrep -e "^${BASE_URL}/\?#[a-zA-Z0-9@_-]{6}\$" || assert_fail 108 "expected link hash url, but got '${LOCATION}'"
# don't follow the redirect => no html => no logout link [ 1 -eq "$(xmllint --html --nowarning --xpath "count(/html/body//a[@href = '?do=logout'])" curl.tmp.html 2>/dev/null)" ] || assert_fail 13 "I expected a logout link."

#####################################################
# TODO: watch out for error messages like e.g. ip bans or the like.

# check post-condition - there must be more entries now:
echo "###################################################"
echo "## Non-logged-in Atom feed after adding a link (should have the added + the initial public default entry):"
curl --silent --show-error --output curl.tmp.atom "${BASE_URL}/?do=atom"
xmllint --encode utf8 --format curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal 2 "${entries}" 119 "Atom feed expected 2 = ${entries}"

D tests/test-tagcloud.sh => tests/test-tagcloud.sh +0 -29
@@ 1,29 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${BASE_URL}" != "" ]         || assert_fail 1 "How strange, BASE_URL is unset."

entries=$(curl --silent "${BASE_URL}/?do=tagcloud" | xsltproc --html ../scripts/tagcloud-html2atom.xslt - | xmllint --relaxng ../scripts/categories.rng - | xmllint --xpath 'count(/*/*[local-name()="category"])' -)
assert_equal "2" "${entries}" 27 "Categories"

exit $?

D tests/test-title.sh => tests/test-title.sh +0 -35
@@ 1,35 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${BASE_URL}" != "" ]         || assert_fail 1 "How strange, BASE_URL is unset."

curl --url "${BASE_URL}" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.html \
  --trace-ascii curl.trace --dump-header curl.head \
  2>/dev/null

title="$(xmllint --html --xpath 'normalize-space(string(/html/body//*[@id="shaarli_title"]))' curl.html 2>/dev/null)"
# ignore @id, use a/@href
title="$(xmllint --html --xpath 'normalize-space(string(/html/body//a[@href="?"]))' curl.html 2>/dev/null)"
assert_equal "Review Shaarli" "${title}" 33 "expected 'Review Shaarli' found '${title}'"

D tests/test_delete-all-ok.sh => tests/test_delete-all-ok.sh +0 -110
@@ 1,110 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
xsltproc --version 1>/dev/null  || assert_fail 102 "I need xsltproc (libxml2)."
[ "${USERNAME}" != "" ]         || assert_fail 1 "How strange, USERNAME is unset."
[ "${PASSWORD}" != "" ]         || assert_fail 2 "How strange, PASSWORD is unset."
[ "${BASE_URL}" != "" ]         || assert_fail 3 "How strange, BASE_URL is unset."
[ -r "$0".xslt ]                || assert_fail 4 "How strange, helper xslt script '$0.xslt' not readable."

echo "####################################################"
echo "## Step 1: fetch token to login "
echo "GET ${BASE_URL}?do=login"
rm curl.tmp.*
# http://unix.stackexchange.com/a/157219
LOCATION=$(curl --get --url "${BASE_URL}/?do=login" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 42 "fetch token error"
TOKEN=$(xmllint --html --nowarning --xpath 'string(/html/body//form[@name="loginform"]//input[@name="token"]/@value)' curl.tmp.html)
# string(..) http://stackoverflow.com/a/18390404

# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 47 "expected TOKEN of 40 characters, but found ${TOKEN} of $(printf "%s" ${TOKEN} | wc -c)"

echo "######################################################"
echo "## Step 2: follow the redirect, do the login and redirect to ${BASE_URL}/? "
echo "POST ${LOCATION}"
rm curl.tmp.*
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 64 "do login error"
assert_equal "${BASE_URL}/?" "${LOCATION}" 65 "redirect to BASE_URL"

# check pre-condition
echo "###################################################"
echo "## Logged-in Atom feed prior doing anything (should have 2 entries)"
curl --url "${BASE_URL}/?do=atom" \
  --silent --show-error \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal "2" "${entries}" 74 "Atom feed entries"

# now figure out the precise lf_linkdate and token for each entry to delete
while true
do
  # re-extract the token from the most recent HTTP response as it's consumed after each
  # HTTP request. So a simple for loop doesn't do the trick.

  line="$(xsltproc --html --nonet "${0}".xslt curl.tmp.html 2>/dev/null | head -n 1)"
  [ "" = "$line" ] && break

  echo "${line}" | while read lf_linkdate token
  do
    echo "lf_linkdate=${lf_linkdate} token=${token}"
    http_code=$(curl --url "${BASE_URL}/" \
      --data-urlencode "lf_linkdate=${lf_linkdate}" \
      --data-urlencode "token=${token}" \
      --data-urlencode "delete_link=" \
      --cookie curl.cook --cookie-jar curl.cook \
      --location --output curl.tmp.html \
      --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
      --write-out '%{http_code}' 2>/dev/null)
    assert_equal "200" "${http_code}" 92 "POST lf_linkdate=${lf_linkdate}&token=..."
    break # process only one line at a time.
  done
done


# check post-condition
echo "###################################################"
echo "## Logged-in Atom feed after deleting all entries"
curl --url "${BASE_URL}/?do=atom" \
  --silent --show-error \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal "0" "${entries}" 103 "Atom feed entries"

D tests/test_delete-ok.sh => tests/test_delete-ok.sh +0 -104
@@ 1,104 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
xsltproc --version 1>/dev/null  || assert_fail 102 "I need xsltproc (libxml2)."
[ "${USERNAME}" != "" ]         || assert_fail 1 "How strange, USERNAME is unset."
[ "${PASSWORD}" != "" ]         || assert_fail 2 "How strange, PASSWORD is unset."
[ "${BASE_URL}" != "" ]         || assert_fail 3 "How strange, BASE_URL is unset."
[ -r "$0".xslt ]                || assert_fail 4 "How strange, helper xslt script '$0.xslt' not readable."

echo "####################################################"
echo "## Step 1: fetch token to login "
echo "GET ${BASE_URL}?do=login"
rm curl.tmp.*
# http://unix.stackexchange.com/a/157219
LOCATION=$(curl --get --url "${BASE_URL}/?do=login" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
# todo:
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 42 "fetch token error"
TOKEN=$(xmllint --html --nowarning --xpath 'string(/html/body//form[@name="loginform"]//input[@name="token"]/@value)' curl.tmp.html)
# string(..) http://stackoverflow.com/a/18390404

# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 47 "expected TOKEN of 40 characters, but found ${TOKEN} of $(printf "%s" ${TOKEN} | wc -c)"

echo "######################################################"
echo "## Step 2: follow the redirect, do the login and redirect to ${BASE_URL}/? "
echo "POST ${LOCATION}"
rm curl.tmp.*
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_equal "" "${errmsg}" 64 "do login error"
assert_equal "${BASE_URL}/?" "${LOCATION}" 65 "redirect to BASE_URL"

# check pre-condition
echo "###################################################"
echo "## Logged-in Atom feed prior doing anything (should have 2 entries)"
curl --url "${BASE_URL}/?do=atom" \
  --silent --show-error \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal "2" "${entries}" 74 "Atom feed entries"


# That's maybe a quirk here – deleting consumes the token, but subsequent
# deletes fail silently. So the loop below deletes only the first entry.

# now figure out the precise lf_linkdate and token for each entry to delete
xsltproc --html --nonet "$0".xslt curl.tmp.html 2>/dev/null | while read lf_linkdate token
do
  echo "lf_linkdate=${lf_linkdate}  token=${token}"
  http_code=$(curl --url "${BASE_URL}/" \
    --data-urlencode "lf_linkdate=${lf_linkdate}" \
    --data-urlencode "token=${token}" \
    --data-urlencode "delete_link=" \
    --cookie curl.cook --cookie-jar curl.cook \
    --location --output curl.tmp.b.html \
    --trace-ascii curl.tmp.b.trace --dump-header curl.tmp.b.head \
    --write-out '%{http_code}' 2>/dev/null)
  assert_equal "200" "${http_code}" 92 "POST lf_linkdate=${lf_linkdate}&token=..."
done

# check post-condition
echo "###################################################"
echo "## Logged-in Atom feed after deleting one entry"
curl --url "${BASE_URL}/?do=atom" \
  --silent --show-error \
  --cookie curl.cook --cookie-jar curl.cook \
  --output curl.tmp.atom
entries=$(xmllint --xpath 'count(/*/*[local-name()="entry"])' curl.tmp.atom)
assert_equal "1" "${entries}" 103 "Atom feed entries"
assert_equal "My secret stuff... - Pastebin.com" "$(xmllint --xpath 'string(/*/*[local-name()="entry"]/*[local-name()="title"])' curl.tmp.atom)" 104 "remaining Atom feed entry title"

D tests/test_login-fail.sh => tests/test_login-fail.sh +0 -134
@@ 1,134 0,0 @@
#!/bin/sh
#
#  Copyright (c) 2015-2016 Marcus Rohrmoser http://mro.name/me. All rights reserved.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
cd "$(dirname "$0")/../tmp"
. ../scripts/assert.sh

# Check preliminaries
curl --version >/dev/null       || assert_fail 101 "I need curl."
xmllint --version 2> /dev/null  || assert_fail 102 "I need xmllint (libxml2)."
[ "${USERNAME}" != "" ]         || assert_fail 1 "How strange, USERNAME is unset."
[ "${PASSWORD}" != "" ]         || assert_fail 2 "How strange, PASSWORD is unset."
[ "${BASE_URL}" != "" ]         || assert_fail 3 "How strange, BASE_URL is unset."

fetch_token() {
  echo "GET $1" 1>&2
  # http://unix.stackexchange.com/a/157219
  LOCATION=$(curl --get --url "$1" \
    --cookie curl.cook --cookie-jar curl.cook \
    --location --output curl.tmp.html \
    --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
    --write-out '%{url_effective}' 2>/dev/null)
  # todo:
  errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
  assert_equal "" "${errmsg}" 107 "error: '${errmsg}'"
  echo $(xmllint --html --nowarning --xpath 'string(/html/body//form[@name="loginform"]//input[@name="token"]/@value)' curl.tmp.html)
  # string(..) http://stackoverflow.com/a/18390404
}

echo "#### Test wrong token"
rm curl.*
LOCATION="${BASE_URL}/?do=login"
TOKEN="just some bogus"

echo "POST ${LOCATION}"
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?do=changepasswd" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_fgrep "alert(\"Wrong login/password.\");document.location='?do=login" "${errmsg}" 59 "expected failure"



echo "#### Test wrong username"
rm curl.*
LOCATION="${BASE_URL}/?do=login"
TOKEN="$(fetch_token "${LOCATION}")"
# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 68 "found TOKEN=${TOKEN}"

echo "POST ${LOCATION}"
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=f o o" \
  --data-urlencode "password=${PASSWORD}" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?do=changepasswd" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_fgrep "alert(\"Wrong login/password.\");document.location='?do=login" "${errmsg}" 81 "expected failure"



echo "#### Test wrong password"
rm curl.*
LOCATION="${BASE_URL}/?do=login"
TOKEN="$(fetch_token "${LOCATION}")"
# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 90 "found TOKEN=${TOKEN}"

echo "POST ${LOCATION}"
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=f o o" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?do=changepasswd" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_fgrep "alert(\"Wrong login/password.\");document.location='?do=login" "${errmsg}" 103 "expected failure"



echo "#### Test wrong password (again)"
rm curl.*
LOCATION="${BASE_URL}/?do=login"
TOKEN="$(fetch_token "${LOCATION}")"
# the precise length doesn't matter, it just has to be significantly larger than ''
assert_equal 40 $(printf "%s" ${TOKEN} | wc -c) 112 "found TOKEN=${TOKEN}"

echo "POST ${LOCATION}"
LOCATION=$(curl --url "${LOCATION}" \
  --data-urlencode "login=${USERNAME}" \
  --data-urlencode "password=f o o" \
  --data-urlencode "token=${TOKEN}" \
  --data-urlencode "returnurl=${BASE_URL}/?do=changepasswd" \
  --cookie curl.cook --cookie-jar curl.cook \
  --location --output curl.tmp.html \
  --trace-ascii curl.tmp.trace --dump-header curl.tmp.head \
  --write-out '%{url_effective}' 2>/dev/null)
errmsg=$(xmllint --html --nowarning --xpath 'string(/html[1 = count(*)]/head[1 = count(*)]/script[starts-with(.,"alert(")])' curl.tmp.html)
assert_fgrep "alert(\"Wrong login/password.\");document.location='?do=login" "${errmsg}" 125 "expected failure"



echo "#### Test banned ip (4 previous failures)"
rm curl.*
LOCATION="${BASE_URL}/?do=login"
TOKEN="$(fetch_token "${LOCATION}")"
errmsg=$(xmllint --html --nowarning --xpath 'string(normalize-space(/html/body//*[@id="headerform"]))' curl.tmp.html)
assert_equal "You have been banned from login after too many failed attempts. Try later." "${errmsg}" 134 "expected failure"

D tmp/.gitkeep => tmp/.gitkeep +0 -0