├── .github ├── dependabot.yml └── workflows │ ├── docker.yml │ ├── ghcr.yml │ └── pastad.yml ├── .gitignore ├── Containerfile ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── pasta │ ├── pasta.go │ └── storage.go └── pastad │ ├── config.go │ ├── pastad.go │ ├── storage.go │ ├── storage__test.go │ └── utils.go ├── docs ├── build.md ├── cloud-init.yaml.example ├── getting-started.md └── index.md ├── go.mod ├── go.sum ├── mime.types ├── pasta.toml.example ├── pastad.toml.example └── test └── test.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: docker image 3 | 4 | 'on': 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v4 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v3 18 | - 19 | name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | - 22 | name: Login to DockerHub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | - 28 | name: Build and push 29 | uses: docker/build-push-action@v6 30 | with: 31 | context: . 32 | platforms: linux/amd64,linux/arm64 33 | push: true 34 | tags: grisu48/pasta:latest 35 | -------------------------------------------------------------------------------- /.github/workflows/ghcr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # See https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages 3 | 4 | name: Create and publish container 5 | 6 | 'on': 7 | release: 8 | types: [published] 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | github-image: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@v6 40 | with: 41 | context: . 42 | push: true 43 | platforms: linux/amd64,linux/arm64 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/pastad.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: pastad 4 | 5 | 'on': 6 | push 7 | 8 | jobs: 9 | pastad: 10 | name: pasta server 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | - name: Setup go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.18' 19 | - name: Install requirements 20 | run: make requirements 21 | - name: Compile binaries 22 | run: make pastad pasta 23 | - name: Run tests 24 | run: make test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | /pastad 8 | /pasta 9 | cmd/pastad/pastad 10 | cmd/pasta/pasta 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # vscode 22 | .vscode 23 | __debug_bin 24 | 25 | # data files, directories and databases 26 | *.db 27 | bins 28 | *.toml 29 | pasta_test 30 | /pastas 31 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM registry.suse.com/bci/golang AS build-env 2 | WORKDIR /app 3 | COPY . /app 4 | RUN cd /app && make requirements && make pastad-static 5 | 6 | FROM scratch 7 | WORKDIR /data 8 | COPY --from=build-env /app/pastad /app/mime.types /app/ 9 | ENTRYPOINT ["/app/pastad", "-m", "/app/mime.types", "-c", "/data/pastad.toml"] 10 | VOLUME ["/data"] 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | Containerfile -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Felix Niederwanger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MANIFEST="pasta-multiarch" 2 | IMAGE="codeberg.org/grisu48/pasta" 3 | 4 | default: all 5 | all: pasta pastad 6 | static: pasta-static pastad-static 7 | 8 | .PHONY: all test clean 9 | 10 | requirements: 11 | go get github.com/BurntSushi/toml 12 | go get github.com/akamensky/argparse 13 | 14 | pasta: cmd/pasta/*.go 15 | go build -o pasta $^ 16 | pastad: cmd/pastad/*.go 17 | go build -o pastad $^ 18 | pasta-static: cmd/pasta/*.go 19 | CGO_ENABLED=0 go build -ldflags="-w -s" -o pasta $^ 20 | pastad-static: cmd/pastad/*.go 21 | CGO_ENABLED=0 go build -ldflags="-w -s" -o pastad $^ 22 | 23 | test: pastad pasta 24 | go test ./... 25 | # TODO: This syntax is horrible :-) 26 | bash -c 'cd test && ./test.sh' 27 | 28 | container: 29 | #podman build . -t codeberg.org/grisu48/pasta 30 | 31 | buildah manifest create "${MANIFEST}" 32 | buildah build --arch amd64 --tag "${IMAGE}" --manifest "${MANIFEST}" . 33 | buildah build --arch arm64 --tag "${IMAGE}" --manifest "${MANIFEST}" . 34 | 35 | container-push: 36 | buildah manifest push --all "${MANIFEST}" "docker://${IMAGE}" 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build status badge](https://github.com/grisu48/pasta/workflows/pastad/badge.svg) 2 | 3 | # pasta 4 | 5 | Stupid simple pastebin service written in go. 6 | 7 | The aim of this project is to create a simple pastebin service for self-hosting. pasta is self-contained, this means it does not need any additional services, e.g. a database to function. All it needs is a data directory and a config `toml` file and it will work. 8 | 9 | This README contains the most important information. See the [docs](docs/index.md) folder for more documentation, e.g. the [getting-started](docs/getting-started.md) guide. 10 | 11 | ## Run as container (podman/docker) 12 | 13 | The easiest way of self-hosting a `pasta` server is via the provided container from `ghcr.io/grisu48/pasta:latest`. Setup your own `pasta` instance is as easy as: 14 | 15 | * Create your `data` directory (holds config + data) 16 | * Create a [pastad.toml](pastad.toml.example) file therein 17 | * Start the container, mount the `data` directory as `/data` and publish port `8199` 18 | * Configure your reverse proxy (e.g. `nginx`) to forward requests to the `pasta` container 19 | 20 | Assuming you want your data directory be e.g. `/srv/pasta`, prepare your server: 21 | 22 | mkdir /srv/pasta 23 | cp pastad.toml.example /srv/pastsa/pastad.toml 24 | $EDITOR /srv/pastsa/pastad.toml # Modify the configuration to your needs 25 | 26 | And then create and run your container with your preferred container engine: 27 | 28 | docker container run -d --name pasta -v /srv/pasta:/data -p 127.0.0.1:8199:8199 ghcr.io/grisu48/pasta 29 | podman container run -d --name pasta -v /srv/pasta:/data -p 127.0.0.1:8199:8199 ghcr.io/grisu48/pasta 30 | 31 | `pasta` listens here on port 8199 and all you need to do is to configure your reverse proxy (e.g. `nginx`) accordingly: 32 | 33 | ```nginx 34 | server { 35 | listen 80; 36 | listen [::]:80; 37 | server_name my-awesome-pasta.server; 38 | 39 | client_max_body_size 32M; 40 | location / { 41 | proxy_pass http://127.0.0.1:8199/; 42 | } 43 | } 44 | ``` 45 | 46 | Note that the good old [dockerhub image](https://hub.docker.com/r/grisu48/pasta/) is deprecated. It still gets updates but will be removed one fine day. 47 | 48 | The container runs fine as rootless container (podman). 49 | 50 | ### environment variables 51 | 52 | In addition to the config file, `pastad` can also be configured via environmental variables. This might be useful for running pasta as a container without a dedicated config file. Supported environmental variables are: 53 | 54 | | Key | Description | 55 | |-----|-------------| 56 | | `PASTA_BASEURL` | Base URL for the pasta instance | 57 | | `PASTA_PASTADIR` | Data directory for pastas | 58 | | `PASTA_BINDADDR` | Address to bind the server to | 59 | | `PASTA_MAXSIZE` | Maximum size (in Bytes) for new pastas | 60 | | `PASTA_CHARACTERS` | Number of characters for new pastas | 61 | | `PASTA_MIMEFILE` | MIME file | 62 | | `PASTA_EXPIRE` | Default expiration time (in seconds) | 63 | | `PASTA_CLEANUP` | Seconds between cleanup cycles | 64 | | `PASTA_REQUESTDELAY` | Delay between requests from the same host in milliseconds | 65 | | `PASTA_PUBLICPASTAS` | Number of public pastas to be displayed | 66 | 67 | ### macros 68 | 69 | The `BASEURL` setting, defined either via configuration file or via the `PASTA_BASEURL` environment variable, supports custom macros, that should help you in various scenarios. Macros are pre-defined strings, which will be replaced. 70 | 71 | The following macros are currently supported 72 | 73 | | Macro | Replaced with | Example | 74 | | `$hostname` | Local hostname | `localhost` | 75 | 76 | A usage example would be to e.g. define the following in your local `pastad.conf` 77 | 78 | ```toml 79 | BaseURL = "http://$hostname:8199" # base URL as used within pasta 80 | ``` 81 | 82 | # Usage 83 | 84 | Assuing the server runs on http://localhost:8199, you can use the `pasta` CLI tool (See below) or `curl`: 85 | 86 | curl -X POST 'http://localhost:8199' --data-binary @README.md 87 | 88 | ## pasta CLI 89 | 90 | `pasta` is the CLI utility for making the creation of a pastas (i.e. files submitted to a pasta server) as easy as possible. 91 | For instance, if you want to push the `README.md` file and create a pasta out of it: 92 | 93 | pasta README.md 94 | pasta -r http://localhost:8199 REAME.md # Define a custom remote server 95 | 96 | `pasta` reads the config from `~/.pasta.toml` (see the [example file](pasta.toml.example)) 97 | -------------------------------------------------------------------------------- /cmd/pasta/pasta.go: -------------------------------------------------------------------------------- 1 | /* 2 | * pasta client 3 | */ 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/BurntSushi/toml" 18 | ) 19 | 20 | const VERSION = "0.7.1" 21 | 22 | type Config struct { 23 | RemoteHost string `toml:"RemoteHost"` 24 | RemoteHosts []RemoteHost `toml:"Remote"` 25 | } 26 | type RemoteHost struct { 27 | URL string `toml:"url"` // URL of the remote host 28 | Alias string `toml:"alias"` // Alias for the remote host 29 | Aliases []string `toml:"aliases"` // List of additional aliases for the remote host 30 | } 31 | 32 | var cf Config 33 | 34 | // Search for the given remote alias. Returns true and the remote if found, otherwise false and an empty instance 35 | func (cf *Config) FindRemoteAlias(remote string) (bool, RemoteHost) { 36 | for _, remote := range cf.RemoteHosts { 37 | if cf.RemoteHost == remote.Alias { 38 | return true, remote 39 | } 40 | for _, alias := range remote.Aliases { 41 | if cf.RemoteHost == alias { 42 | return true, remote 43 | } 44 | } 45 | } 46 | var ret RemoteHost 47 | return false, ret 48 | } 49 | 50 | /* http error instance */ 51 | type HttpError struct { 52 | err string 53 | StatusCode int 54 | } 55 | 56 | func (e *HttpError) Error() string { 57 | return e.err 58 | } 59 | 60 | func FileExists(filename string) bool { 61 | _, err := os.Stat(filename) 62 | if err != nil { 63 | return false 64 | } 65 | return !os.IsNotExist(err) 66 | } 67 | 68 | func usage() { 69 | fmt.Printf("Usage: %s [OPTIONS] [FILE,[FILE2,...]]\n\n", os.Args[0]) 70 | fmt.Println("OPTIONS") 71 | fmt.Println(" -h, --help Print this help message") 72 | fmt.Println(" -r, --remote HOST Define remote host or alias (Default: http://localhost:8199)") 73 | fmt.Println(" -c, --config FILE Define config file (Default: ~/.pasta.toml)") 74 | fmt.Println(" -f, --file FILE Send FILE to server") 75 | fmt.Println("") 76 | fmt.Println(" --ls, --list List known pasta pushes") 77 | fmt.Println(" --gc Garbage collector (clean expired pastas)") 78 | fmt.Println(" --version Show client version") 79 | fmt.Println("") 80 | fmt.Println("One or more files can be pushed to the server.") 81 | fmt.Println("If no file is given, the input from stdin will be pushed.") 82 | } 83 | 84 | func push(filename string, mime string, src io.Reader) (Pasta, error) { 85 | pasta := Pasta{} 86 | 87 | client := &http.Client{} 88 | // For compatability reasons, set the return format in URL and header for some time 89 | req, err := http.NewRequest("POST", cf.RemoteHost+"?ret=json", src) 90 | if err != nil { 91 | return pasta, err 92 | } 93 | req.Header.Set("Return-Format", "json") 94 | if mime != "" { 95 | req.Header.Set("Content-Type", mime) 96 | } 97 | if filename != "" { 98 | req.Header.Set("Filename", filename) 99 | } 100 | resp, err := client.Do(req) 101 | if err != nil { 102 | return pasta, err 103 | } 104 | defer resp.Body.Close() 105 | if resp.StatusCode != 200 { 106 | return pasta, fmt.Errorf("http status code: %d", resp.StatusCode) 107 | } 108 | pasta.Date = time.Now().Unix() 109 | err = json.NewDecoder(resp.Body).Decode(&pasta) 110 | if err != nil { 111 | return pasta, err 112 | } 113 | return pasta, nil 114 | } 115 | 116 | func httpRequest(url string, method string) error { 117 | client := &http.Client{} 118 | req, err := http.NewRequest(method, url, nil) 119 | if err != nil { 120 | return err 121 | } 122 | resp, err := client.Do(req) 123 | if err != nil { 124 | return err 125 | } 126 | if resp.StatusCode == 200 { 127 | return nil 128 | } else { 129 | // Try to fetch a small error message 130 | buf := make([]byte, 200) 131 | n, err := resp.Body.Read(buf) 132 | if err != nil || n == 0 || n >= 200 { 133 | return &HttpError{err: fmt.Sprintf("http code %d", resp.StatusCode), StatusCode: resp.StatusCode} 134 | } 135 | return &HttpError{err: fmt.Sprintf("http code %d: %s", resp.StatusCode, string(buf)), StatusCode: resp.StatusCode} 136 | } 137 | } 138 | 139 | func rm(pasta Pasta) error { 140 | url := fmt.Sprintf("%s?token=%s", pasta.Url, pasta.Token) 141 | if err := httpRequest(url, "DELETE"); err != nil { 142 | // Ignore 404 errors, because that means that the pasta is remove on the server (e.g. expired) 143 | if strings.HasPrefix(err.Error(), "http code 404") { 144 | return nil 145 | } 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | func getFilename(filename string) string { 152 | i := strings.LastIndex(filename, "/") 153 | if i < 0 { 154 | return filename 155 | } else { 156 | return filename[i+1:] 157 | } 158 | } 159 | 160 | /* Try to parse an integer range (1..2 or 5-9) - returns the range and a boolean indicating, if such a range could have been parsed */ 161 | func tryParseRange(txt string) (int, int, bool) { 162 | if txt == "" { 163 | return 0, 0, false 164 | } 165 | if i := strings.Index(txt, "-"); i > 0 { 166 | if i == 0 || i >= len(txt)-1 { 167 | // Incomplete range 168 | return 0, 0, false 169 | } 170 | l, r := txt[:i], txt[i+1:] 171 | // Try to parse 172 | i, err := strconv.Atoi(l) 173 | if err != nil { 174 | return 0, 0, false 175 | } 176 | j, err := strconv.Atoi(r) 177 | if err != nil { 178 | return 0, 0, false 179 | } 180 | return i, j, true 181 | } 182 | if i := strings.Index(txt, ".."); i > 1 { 183 | if i == 0 || i >= len(txt)-2 { 184 | // Incomplete range 185 | return 0, 0, false 186 | } 187 | l, r := txt[:i], txt[i+2:] 188 | // Try to parse 189 | i, err := strconv.Atoi(l) 190 | if err != nil { 191 | return 0, 0, false 192 | } 193 | j, err := strconv.Atoi(r) 194 | if err != nil { 195 | return 0, 0, false 196 | } 197 | return i, j, true 198 | } 199 | return 0, 0, false 200 | } 201 | 202 | func main() { 203 | cf.RemoteHost = "http://localhost:8199" 204 | action := "" 205 | // Load configuration file if possible 206 | homeDir, _ := os.UserHomeDir() 207 | configFile := homeDir + "/.pasta.toml" 208 | if FileExists(configFile) { 209 | if _, err := toml.DecodeFile(configFile, &cf); err != nil { 210 | fmt.Fprintf(os.Stderr, "config-toml file parse error: %s %s\n", configFile, err) 211 | } 212 | } 213 | // Files to be pushed 214 | files := make([]string, 0) 215 | explicit := false // marking files as explicitly given. This disabled the shortcut commands (ls, rm, gc) 216 | // Parse program arguments 217 | args := os.Args[1:] 218 | for i := 0; i < len(args); i++ { 219 | arg := args[i] 220 | if arg == "" { 221 | continue 222 | } 223 | if arg[0] == '-' { 224 | if arg == "-h" || arg == "--help" { 225 | usage() 226 | os.Exit(0) 227 | } else if arg == "-r" || arg == "--remote" { 228 | i++ 229 | cf.RemoteHost = args[i] 230 | } else if arg == "-c" || arg == "--config" { 231 | i++ 232 | if _, err := toml.DecodeFile(args[i], &cf); err != nil { 233 | fmt.Fprintf(os.Stderr, "config-toml file parse error: %s %s\n", configFile, err) 234 | } 235 | } else if arg == "-f" || arg == "--file" { 236 | i++ 237 | explicit = true 238 | files = append(files, args[i]) 239 | } else if arg == "--ls" || arg == "--list" { 240 | action = "list" 241 | } else if arg == "--rm" || arg == "--remote" || arg == "--delete" { 242 | action = "rm" 243 | } else if arg == "--gc" { 244 | action = "gc" 245 | } else if arg == "--version" { 246 | fmt.Printf("pasta version %s\n", VERSION) 247 | os.Exit(1) 248 | } else if arg == "--" { 249 | // The rest are filenames 250 | if i+1 < len(args) { 251 | files = append(files, args[i+1:]...) 252 | } 253 | i = len(args) 254 | continue 255 | } else { 256 | fmt.Fprintf(os.Stderr, "Invalid argument: %s\n", arg) 257 | os.Exit(1) 258 | } 259 | } else { 260 | files = append(files, arg) 261 | } 262 | } 263 | if found, remote := cf.FindRemoteAlias(cf.RemoteHost); found { 264 | fmt.Fprintf(os.Stderr, "Alias found: %s for %s\n", cf.RemoteHost, remote.URL) 265 | cf.RemoteHost = remote.URL 266 | } 267 | // Sanity checks 268 | if !strings.Contains(cf.RemoteHost, "://") { 269 | fmt.Fprintf(os.Stderr, "Invalid remote: %s\n", cf.RemoteHost) 270 | os.Exit(1) 271 | } 272 | // Load stored pastas 273 | stor, err := OpenStorage(homeDir + "/.pastas.dat") 274 | if err != nil { 275 | fmt.Fprintf(os.Stderr, "Cannot open pasta storage: %s\n", err) 276 | } 277 | 278 | if !explicit { 279 | // Special action: "pasta ls" list pasta 280 | if action == "" && len(files) == 1 && files[0] == "ls" { 281 | if FileExists(files[0]) { 282 | fmt.Fprintf(os.Stderr, "Ambiguous command %s (file '%s' exists) - please use '-f %s' to upload or --ls to list pastas\n", files[0], files[0], files[0]) 283 | os.Exit(1) 284 | } 285 | action = "list" 286 | files = make([]string, 0) 287 | } 288 | // Special action: "pasta rm" is the same as "pasta --rm" 289 | if len(files) > 1 && files[0] == "rm" { 290 | if FileExists(files[0]) { 291 | fmt.Fprintf(os.Stderr, "Ambiguous command %s (file '%s' exists) - please use '-f %s' to upload or --rm to remove pastas\n", files[0], files[0], files[0]) 292 | os.Exit(1) 293 | } 294 | action = "rm" 295 | files = files[1:] 296 | } 297 | // Special action: "pasta gc" is the same as "pasta --gc" 298 | if len(files) == 1 && (files[0] == "gc" || files[0] == "clean" || files[0] == "expire") { 299 | if FileExists(files[0]) { 300 | fmt.Fprintf(os.Stderr, "Ambiguous command %s (file '%s' exists) - please use '-f %s' to upload or --gc to cleanup expired pastas\n", files[0], files[0], files[0]) 301 | os.Exit(1) 302 | } 303 | action = "gc" 304 | files = files[1:] 305 | } 306 | } 307 | 308 | if action == "push" || action == "" { 309 | if len(files) > 0 { 310 | for _, filename := range files { 311 | file, err := os.OpenFile(filename, os.O_RDONLY, 0400) 312 | if err != nil { 313 | fmt.Fprintf(os.Stderr, "%s: %s\n", filename, err) 314 | os.Exit(1) 315 | } 316 | defer file.Close() 317 | if stat, err := file.Stat(); err != nil { 318 | fmt.Fprintf(os.Stderr, "%s: %s\n", filename, err) 319 | os.Exit(1) 320 | } else if stat.Size() == 0 { 321 | fmt.Fprintf(os.Stderr, "Skipping empty file %s\n", filename) 322 | continue 323 | } 324 | // Push file 325 | f_name := getFilename(filename) 326 | pasta, err := push(f_name, "", file) 327 | pasta.Filename = f_name 328 | if err != nil { 329 | fmt.Fprintf(os.Stderr, "%s\n", err) 330 | os.Exit(1) 331 | } 332 | if err = stor.Append(pasta); err != nil { 333 | fmt.Fprintf(os.Stderr, "Cannot writing pasta to local store: %s\n", err) 334 | } 335 | // For a single file just print the link 336 | if len(files) == 1 { 337 | fmt.Printf("%s\n", pasta.Url) 338 | } else { 339 | fmt.Printf("%s - %s\n", pasta.Filename, pasta.Url) 340 | } 341 | } 342 | } else { 343 | fmt.Fprintln(os.Stderr, "Reading from stdin") 344 | reader := bufio.NewReader(os.Stdin) 345 | pasta, err := push("", "text/plain", reader) 346 | if err != nil { 347 | fmt.Fprintf(os.Stderr, "%s\n", err) 348 | os.Exit(1) 349 | } 350 | if err = stor.Append(pasta); err != nil { 351 | fmt.Fprintf(os.Stderr, "Cannot writing pasta to local store: %s\n", err) 352 | } 353 | fmt.Println(pasta.Url) 354 | } 355 | } else if action == "list" { // list known pastas 356 | if len(stor.Pastas) > 0 { 357 | fmt.Printf("Id %-30s %-19s %s\n", "Filename", "Date", "URL") 358 | for i, pasta := range stor.Pastas { 359 | t := time.Unix(pasta.Date, 0) 360 | filename := pasta.Filename 361 | if filename == "" { 362 | filename = "" 363 | } 364 | fmt.Printf("%-3d %-30s %-19s %s\n", i, filename, t.Format("2006-01-02 15:04:05"), pasta.Url) 365 | } 366 | } 367 | } else if action == "rm" { // remove pastas 368 | // List of pastas to be deleted 369 | spoiled := make([]Pasta, 0) 370 | // Match given pastas and get tokens 371 | for _, file := range files { 372 | // If it is and integer, take the n-th item 373 | if id, err := strconv.Atoi(file); err == nil { 374 | if id < 0 || id >= len(stor.Pastas) { 375 | fmt.Fprintf(os.Stderr, "Cannot find pasta '%d'\n", id) 376 | os.Exit(1) 377 | } 378 | if id < 0 || id >= len(stor.Pastas) { 379 | fmt.Fprintf(os.Stderr, "Pasta index %d out of range\n", id) 380 | os.Exit(1) 381 | } 382 | spoiled = append(spoiled, stor.Pastas[id]) 383 | // If it is a range (e.g. 3-4 or 3..4) use the i..j items 384 | } else if l, r, found := tryParseRange(file); found { 385 | // First ensure that the given string is not a file. Files have precedence 386 | if pasta, ok := stor.Get(file); ok { 387 | spoiled = append(spoiled, pasta) 388 | } else { 389 | // Assume it's a range 390 | if l < 0 { 391 | fmt.Fprintf(os.Stderr, "Pasta index %d out of range\n", l) 392 | os.Exit(1) 393 | } 394 | if r >= len(stor.Pastas) { 395 | fmt.Fprintf(os.Stderr, "Pasta index %d out of range\n", r) 396 | os.Exit(1) 397 | } 398 | for i := l; i <= r; i++ { 399 | spoiled = append(spoiled, stor.Pastas[i]) 400 | } 401 | } 402 | } else { 403 | if pasta, ok := stor.Get(file); ok { 404 | spoiled = append(spoiled, pasta) 405 | } else { 406 | // Stop execution 407 | fmt.Fprintf(os.Stderr, "Cannot find pasta '%s'\n", file) 408 | os.Exit(1) 409 | } 410 | } 411 | } 412 | 413 | // Delete found pastas 414 | for _, pasta := range spoiled { 415 | if err := rm(pasta); err != nil { 416 | fmt.Fprintf(os.Stderr, "Error deleting '%s': %s\n", pasta.Url, err) 417 | } else { 418 | fmt.Printf("Deleted: %s\n", pasta.Url) 419 | stor.Remove(pasta.Url, pasta.Token) // Mark as removed for when rewriting storage 420 | } 421 | } 422 | // And re-write storage 423 | if err = stor.Write(); err != nil { 424 | fmt.Fprintf(os.Stderr, "Error writing to local storage: %s\n", err) 425 | } 426 | } else if action == "gc" || action == "clean" { 427 | // Cleanup happens when loading pastas 428 | expired := stor.ExpiredPastas() 429 | if expired == 0 { 430 | fmt.Println("all good") 431 | } else if expired == 1 { 432 | fmt.Println("one expired pasta cleared") 433 | } else { 434 | fmt.Printf("%d expired pastas cleared\n", expired) 435 | } 436 | } else { 437 | fmt.Fprintf(os.Stderr, "Unkown action: %s\n", action) 438 | os.Exit(1) 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /cmd/pasta/storage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type Pasta struct { 13 | Url string `json:"url"` 14 | Token string `json:"token"` 15 | Date int64 `json:"date"` 16 | Expire int64 `json:"expire"` 17 | Filename string `json:"filename"` 18 | } 19 | 20 | type Storage struct { 21 | Pastas []Pasta 22 | file *os.File 23 | filename string 24 | expired int // number of expired pastas when loading 25 | } 26 | 27 | /* Format for writing to storage*/ 28 | func (pasta *Pasta) format() string { 29 | return fmt.Sprintf("%s:%d:%d:%s:%s", pasta.Token, pasta.Date, pasta.Expire, strings.Replace(pasta.Filename, ":", "", -1), pasta.Url) 30 | } 31 | 32 | func OpenStorage(filename string) (Storage, error) { 33 | stor := Storage{filename: filename} 34 | return stor, stor.Open(filename) 35 | } 36 | 37 | func (stor *Storage) Open(filename string) error { 38 | var err error 39 | stor.filename = filename 40 | stor.file, err = os.OpenFile(filename, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0640) 41 | if err != nil { 42 | return err 43 | } 44 | stor.Pastas = make([]Pasta, 0) 45 | dirty := false // dirty flag used to rewrite the file if some pastas are expired 46 | stor.expired = 0 47 | now := time.Now().Unix() 48 | // Read file 49 | scanner := bufio.NewScanner(stor.file) 50 | for scanner.Scan() { 51 | if err := scanner.Err(); err != nil { 52 | stor.file.Close() 53 | stor.file = nil 54 | return err 55 | } 56 | split := strings.Split(scanner.Text(), ":") 57 | if len(split) < 5 { 58 | continue 59 | } 60 | pasta := Pasta{Token: split[0], Filename: split[3], Url: strings.Join(split[4:], ":")} 61 | pasta.Date, _ = strconv.ParseInt(split[1], 10, 64) 62 | pasta.Expire, _ = strconv.ParseInt(split[2], 10, 64) 63 | // Don't add expired pastas and mark storage as dirty for re-write in the end 64 | if pasta.Expire != 0 && now > pasta.Expire { 65 | dirty = true 66 | stor.expired++ 67 | } else { 68 | stor.Pastas = append(stor.Pastas, pasta) 69 | } 70 | } 71 | 72 | // Rewrite storage if expired pastas have been removed 73 | if dirty { 74 | return stor.Write() 75 | } 76 | return nil 77 | } 78 | 79 | func (stor *Storage) Close() error { 80 | if stor.file == nil { 81 | return nil 82 | } 83 | return stor.file.Close() 84 | } 85 | 86 | func (stor *Storage) Append(pasta Pasta) error { 87 | if _, err := stor.file.Write([]byte(pasta.format() + "\n")); err != nil { 88 | return err 89 | } 90 | return stor.file.Sync() 91 | } 92 | 93 | /* Rewrite the whole storage file */ 94 | func (stor *Storage) Write() error { 95 | var err error 96 | stor.file.Close() 97 | stor.file, err = os.OpenFile(stor.filename, os.O_RDWR|os.O_TRUNC, 0640) 98 | if err != nil { 99 | return err 100 | } 101 | for _, pasta := range stor.Pastas { 102 | if pasta.Url == "" { 103 | continue 104 | } 105 | _, err = stor.file.Write([]byte(pasta.format() + "\n")) 106 | if err != nil { 107 | return err 108 | } 109 | } 110 | return stor.file.Sync() 111 | } 112 | 113 | func (stor *Storage) ExpiredPastas() int { 114 | return stor.expired 115 | } 116 | 117 | func getPastaId(url string) string { 118 | i := strings.LastIndex(url, "/") 119 | if i < 0 { 120 | return url 121 | } 122 | return url[i+1:] 123 | } 124 | 125 | func (stor *Storage) Get(id string) (Pasta, bool) { 126 | // If the id is a url, check for url match first 127 | if strings.Contains(id, "://") { 128 | for _, pasta := range stor.Pastas { 129 | if pasta.Url == id { 130 | return pasta, true 131 | } 132 | } 133 | } 134 | // Check for pasta ID only. This needs to happen as second step als url matching has precedence 135 | for _, pasta := range stor.Pastas { 136 | if pasta.Url == id { 137 | return pasta, true 138 | } 139 | } 140 | 141 | // Nothing found, return empty pasta 142 | return Pasta{}, false 143 | } 144 | 145 | func (stor *Storage) find(url string, token string) int { 146 | for i, pasta := range stor.Pastas { 147 | if pasta.Url == url && pasta.Token == token { 148 | return i 149 | } 150 | } 151 | return -1 152 | } 153 | 154 | /** Marks the given pasta (given by url and token) as removed from storage. Returns true if the pasta is found, false if not found*/ 155 | func (stor *Storage) Remove(url string, token string) bool { 156 | i := stor.find(url, token) 157 | if i < 0 { 158 | return false 159 | } 160 | after := stor.Pastas[i+1:] 161 | stor.Pastas = stor.Pastas[:i] 162 | stor.Pastas = append(stor.Pastas, after...) 163 | return true 164 | 165 | } 166 | -------------------------------------------------------------------------------- /cmd/pastad/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type Config struct { 9 | BaseUrl string `toml:"BaseURL"` // Instance base URL 10 | PastaDir string `toml:"PastaDir"` // dir where pasta are stored 11 | BindAddr string `toml:"BindAddress"` 12 | MaxPastaSize int64 `toml:"MaxPastaSize"` // Max bin size in bytes 13 | PastaCharacters int `toml:"PastaCharacters"` 14 | MimeTypesFile string `toml:"MimeTypes"` // Load mime types from this file 15 | DefaultExpire int64 `toml:"Expire"` // Default expire time for a new pasta in seconds 16 | CleanupInterval int `toml:"Cleanup"` // Seconds between cleanup cycles 17 | RequestDelay int64 `toml:"RequestDelay"` // Required delay between requests in milliseconds 18 | PublicPastas int `toml:"PublicPastas"` // Number of pastas to display on public page or 0 to disable 19 | } 20 | 21 | type ParserConfig struct { 22 | ConfigFile *string 23 | BaseURL *string 24 | PastaDir *string 25 | BindAddr *string 26 | MaxPastaSize *int // parser doesn't support int64 27 | PastaCharacters *int 28 | MimeTypesFile *string 29 | DefaultExpire *int // parser doesn't support int64 30 | CleanupInterval *int 31 | PublicPastas *int 32 | } 33 | 34 | func CreateDefaultConfigfile(filename string) error { 35 | hostname, _ := os.Hostname() 36 | if hostname == "" { 37 | hostname = "localhost" 38 | } 39 | content := []byte(fmt.Sprintf("BaseURL = 'http://%s:8199'\nBindAddress = ':8199'\nPastaDir = 'pastas'\nMaxPastaSize = 5242880 # 5 MiB\nPastaCharacters = 8\nExpire = 2592000 # 1 month\nCleanup = 3600 # cleanup interval in seconds\nRequestDelay = 2000\nPublicPastas = 0\n", hostname)) 40 | file, err := os.Create(filename) 41 | if err != nil { 42 | return err 43 | } 44 | defer file.Close() 45 | if _, err = file.Write(content); err != nil { 46 | return err 47 | } 48 | if err := file.Chmod(0640); err != nil { 49 | return err 50 | } 51 | return file.Close() 52 | } 53 | 54 | // SetDefaults sets the default values to a config instance 55 | func (cf *Config) SetDefaults() { 56 | cf.BaseUrl = "http://localhost:8199" 57 | cf.PastaDir = "pastas/" 58 | cf.BindAddr = "127.0.0.1:8199" 59 | cf.MaxPastaSize = 1024 * 1024 * 25 // Default max size: 25 MB 60 | cf.PastaCharacters = 8 // Note: Never use less than 8 characters! 61 | cf.MimeTypesFile = "mime.types" 62 | cf.DefaultExpire = 0 63 | cf.CleanupInterval = 60 * 60 // Default cleanup is once per hour 64 | cf.RequestDelay = 0 // By default not spam protection (Assume we are in safe environment) 65 | cf.PublicPastas = 0 66 | } 67 | 68 | // ReadEnv reads the environmental variables and sets the config accordingly 69 | func (cf *Config) ReadEnv() { 70 | cf.BaseUrl = getenv("PASTA_BASEURL", cf.BaseUrl) 71 | cf.PastaDir = getenv("PASTA_PASTADIR", cf.PastaDir) 72 | cf.BindAddr = getenv("PASTA_BINDADDR", cf.BindAddr) 73 | cf.MaxPastaSize = getenv_i64("PASTA_MAXSIZE", cf.MaxPastaSize) 74 | cf.PastaCharacters = getenv_i("PASTA_CHARACTERS", cf.PastaCharacters) 75 | cf.MimeTypesFile = getenv("PASTA_MIMEFILE", cf.MimeTypesFile) 76 | cf.DefaultExpire = getenv_i64("PASTA_EXPIRE", cf.DefaultExpire) 77 | cf.CleanupInterval = getenv_i("PASTA_CLEANUP", cf.CleanupInterval) 78 | cf.RequestDelay = getenv_i64("PASTA_REQUESTDELAY", cf.RequestDelay) 79 | cf.PublicPastas = getenv_i("PASTA_PUBLICPASTAS", cf.PublicPastas) 80 | } 81 | 82 | func (pc *ParserConfig) ApplyTo(cf *Config) { 83 | if pc.BaseURL != nil && *pc.BaseURL != "" { 84 | cf.BaseUrl = *pc.BaseURL 85 | } 86 | if pc.PastaDir != nil && *pc.PastaDir != "" { 87 | cf.PastaDir = *pc.PastaDir 88 | } 89 | if pc.BindAddr != nil && *pc.BindAddr != "" { 90 | cf.BindAddr = *pc.BindAddr 91 | } 92 | if pc.MaxPastaSize != nil && *pc.MaxPastaSize > 0 { 93 | cf.MaxPastaSize = int64(*pc.MaxPastaSize) 94 | } 95 | if pc.PastaCharacters != nil && *pc.PastaCharacters > 0 { 96 | cf.PastaCharacters = *pc.PastaCharacters 97 | } 98 | if pc.MimeTypesFile != nil && *pc.MimeTypesFile != "" { 99 | cf.MimeTypesFile = *pc.MimeTypesFile 100 | } 101 | if pc.DefaultExpire != nil && *pc.DefaultExpire > 0 { 102 | cf.DefaultExpire = int64(*pc.DefaultExpire) 103 | } 104 | if pc.CleanupInterval != nil && *pc.CleanupInterval > 0 { 105 | cf.CleanupInterval = *pc.CleanupInterval 106 | } 107 | if pc.PublicPastas != nil && *pc.PublicPastas > 0 { 108 | cf.PublicPastas = *pc.PublicPastas 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /cmd/pastad/pastad.go: -------------------------------------------------------------------------------- 1 | /* 2 | * pasted - stupid simple paste server 3 | */ 4 | 5 | package main 6 | 7 | import ( 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "os" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "github.com/BurntSushi/toml" 21 | "github.com/akamensky/argparse" 22 | ) 23 | 24 | const VERSION = "0.7" 25 | 26 | var cf Config 27 | var bowl PastaBowl 28 | var publicPastas []Pasta 29 | var mimeExtensions map[string]string 30 | var delays map[string]int64 31 | var delayMutex sync.Mutex 32 | 33 | func SendPasta(pasta Pasta, w http.ResponseWriter) error { 34 | file, err := bowl.GetPastaReader(pasta.Id) 35 | if err != nil { 36 | return err 37 | } 38 | defer file.Close() 39 | w.Header().Set("Content-Disposition", "inline") 40 | w.Header().Set("Content-Length", strconv.FormatInt(pasta.Size, 10)) 41 | if pasta.Mime != "" { 42 | w.Header().Set("Content-Type", pasta.Mime) 43 | } 44 | if pasta.ContentFilename != "" { 45 | w.Header().Set("Filename", pasta.ContentFilename) 46 | 47 | } 48 | _, err = io.Copy(w, file) 49 | return err 50 | } 51 | 52 | func removePublicPasta(id string) { 53 | copy := make([]Pasta, 0) 54 | for _, pasta := range publicPastas { 55 | if pasta.Id != id { 56 | copy = append(copy, pasta) 57 | } 58 | } 59 | publicPastas = copy 60 | } 61 | 62 | func deletePasta(id string, token string, w http.ResponseWriter) { 63 | var pasta Pasta 64 | var err error 65 | if id == "" || token == "" { 66 | goto Invalid 67 | } 68 | pasta, err = bowl.GetPasta(id) 69 | if err != nil { 70 | log.Fatalf("Error getting pasta %s: %s", pasta.Id, err) 71 | goto ServerError 72 | } 73 | if pasta.Id == "" { 74 | goto NotFound 75 | } 76 | if pasta.Token == token { 77 | err = bowl.DeletePasta(pasta.Id) 78 | if err != nil { 79 | log.Fatalf("Error deleting pasta %s: %s", pasta.Id, err) 80 | goto ServerError 81 | } 82 | // Also remove from public pastas, if present 83 | removePublicPasta(pasta.Id) 84 | 85 | w.WriteHeader(200) 86 | fmt.Fprintf(w, "\n", cf.BaseUrl) 87 | fmt.Fprintf(w, "\n") 88 | fmt.Fprintf(w, "

OK - Redirecting to main page ...

") 89 | fmt.Fprintf(w, "\n\n") 90 | } else { 91 | goto Invalid 92 | } 93 | return 94 | NotFound: 95 | w.WriteHeader(404) 96 | fmt.Fprintf(w, "pasta not found") 97 | return 98 | Invalid: 99 | w.WriteHeader(403) 100 | fmt.Fprintf(w, "Invalid request") 101 | return 102 | ServerError: 103 | w.WriteHeader(500) 104 | fmt.Fprintf(w, "server error") 105 | } 106 | 107 | func receive(reader io.Reader, pasta *Pasta) error { 108 | buf := make([]byte, 4096) 109 | file, err := os.OpenFile(pasta.DiskFilename, os.O_RDWR|os.O_APPEND, 0640) 110 | if err != nil { 111 | file.Close() 112 | return err 113 | } 114 | defer file.Close() 115 | pasta.Size = 0 116 | for pasta.Size < cf.MaxPastaSize { 117 | n, err := reader.Read(buf) 118 | if (err == nil || err == io.EOF) && n > 0 { 119 | if _, err = file.Write(buf[:n]); err != nil { 120 | log.Fatalf("Write error while receiving bin: %s", err) 121 | return err 122 | } 123 | pasta.Size += int64(n) 124 | } 125 | if err != nil { 126 | if err == io.EOF { 127 | return nil 128 | } 129 | log.Fatalf("Receive error while receiving bin: %s", err) 130 | return err 131 | } 132 | } 133 | return nil 134 | } 135 | 136 | func receiveMultibody(r *http.Request, pasta *Pasta) (io.ReadCloser, bool, error) { 137 | public := false 138 | filename := "" 139 | 140 | // Read http headers first 141 | value := r.Header.Get("public") 142 | if value != "" { 143 | public = strBool(value, public) 144 | } 145 | // If the content length is given, reject immediately if the size is too big 146 | size := r.Header.Get("Content-Length") 147 | if size != "" { 148 | size, err := strconv.ParseInt(size, 10, 64) 149 | if err == nil && size > 0 && size > cf.MaxPastaSize { 150 | log.Println("Max size exceeded (Content-Length)") 151 | return nil, public, errors.New("content size exceeded") 152 | } 153 | } 154 | 155 | // Receive multipart form 156 | err := r.ParseMultipartForm(cf.MaxPastaSize) 157 | if err != nil { 158 | return nil, public, err 159 | } 160 | file, header, err := r.FormFile("file") 161 | if err != nil { 162 | return nil, public, err 163 | } 164 | 165 | // Read file headers 166 | filename = header.Filename 167 | if filename != "" { 168 | pasta.ContentFilename = filename 169 | } 170 | 171 | // Read form values after headers, as the form values have precedence 172 | form := r.MultipartForm 173 | values := form.Value 174 | if value, ok := values["public"]; ok { 175 | if len(value) > 0 { 176 | public = strBool(value[0], public) 177 | } 178 | } 179 | 180 | // Determine MIME type based on file extension, if present 181 | if filename != "" { 182 | pasta.Mime = mimeByFilename(filename) 183 | } else { 184 | pasta.Mime = "application/octet-stream" 185 | } 186 | 187 | return file, public, err 188 | } 189 | 190 | /* Parse expire header value. Returns expire value or 0 on error or invalid settings */ 191 | func parseExpire(headerValue []string) int64 { 192 | var ret int64 193 | for _, value := range headerValue { 194 | if expire, err := strconv.ParseInt(value, 10, 64); err == nil { 195 | // No negative values allowed 196 | if expire < 0 { 197 | return 0 198 | } 199 | ret = time.Now().Unix() + int64(expire) 200 | } 201 | } 202 | return ret 203 | } 204 | 205 | /* isMultipart returns true if the given request is multipart form */ 206 | func isMultipart(r *http.Request) bool { 207 | contentType := r.Header.Get("Content-Type") 208 | return contentType == "multipart/form-data" || strings.HasPrefix(contentType, "multipart/form-data;") 209 | } 210 | 211 | func ReceivePasta(r *http.Request) (Pasta, bool, error) { 212 | var err error 213 | var reader io.ReadCloser 214 | pasta := Pasta{Id: ""} 215 | public := false 216 | 217 | // Parse expire if given 218 | if cf.DefaultExpire > 0 { 219 | pasta.ExpireDate = time.Now().Unix() + cf.DefaultExpire 220 | } 221 | if expire := parseExpire(r.Header["Expire"]); expire > 0 { 222 | pasta.ExpireDate = expire 223 | // TODO: Add maximum expiration parameter 224 | } 225 | 226 | pasta.Id = removeNonAlphaNumeric(bowl.GenerateRandomBinId(cf.PastaCharacters)) 227 | formRead := true // Read values from the form 228 | if isMultipart(r) { 229 | // InsertPasta to obtain a filename 230 | if err = bowl.InsertPasta(&pasta); err != nil { 231 | return pasta, public, err 232 | } 233 | reader, public, err = receiveMultibody(r, &pasta) 234 | if err != nil { 235 | bowl.DeletePasta(pasta.Id) 236 | pasta.Id = "" 237 | return pasta, public, err 238 | } 239 | } else { 240 | // Check if the input is coming from the POST form 241 | inputs := r.URL.Query()["input"] 242 | if len(inputs) > 0 && inputs[0] == "form" { 243 | // Copy reader, as r.FromValue consumes it's contents 244 | defer r.Body.Close() 245 | if content := r.FormValue("content"); content != "" { 246 | reader = io.NopCloser(strings.NewReader(content)) 247 | } else { 248 | pasta.Id = "" // Empty pasta 249 | return pasta, public, nil 250 | } 251 | } else { 252 | reader = r.Body 253 | formRead = false 254 | } 255 | } 256 | defer reader.Close() 257 | 258 | header := r.Header 259 | // If the content length is given, reject immediately if the size is too big 260 | size := header.Get("Content-Length") 261 | if size != "" { 262 | size, err := strconv.ParseInt(size, 10, 64) 263 | if err == nil && size > 0 && size > cf.MaxPastaSize { 264 | log.Println("Max size exceeded (Content-Length)") 265 | return pasta, public, errors.New("content size exceeded") 266 | } 267 | } 268 | // Get property. URL parameter has precedence over header 269 | prop_get := func(name string) string { 270 | var val string 271 | if formRead { 272 | val = r.FormValue(name) 273 | if val != "" { 274 | return val 275 | } 276 | } 277 | val = header.Get(name) 278 | if val != "" { 279 | return val 280 | } 281 | return "" 282 | } 283 | // Check if public 284 | value := prop_get("public") 285 | if value != "" { 286 | public = strBool(value, public) 287 | } 288 | // Apply filename, if present 289 | // Due to inconsitent naming between URL and http parameters, we have to check for Filename and filename. URL parameters have precedence 290 | filename := prop_get("filename") 291 | if filename != "" { 292 | pasta.ContentFilename = filename 293 | } else { 294 | filename := prop_get("Filename") 295 | if filename != "" { 296 | pasta.ContentFilename = filename 297 | } 298 | } 299 | 300 | // InsertPasta sets filename 301 | if err = bowl.InsertPasta(&pasta); err != nil { 302 | return pasta, public, err 303 | } 304 | if err := receive(reader, &pasta); err != nil { 305 | return pasta, public, err 306 | } 307 | if pasta.Size >= cf.MaxPastaSize { 308 | log.Println("Max size exceeded while receiving bin") 309 | return pasta, public, errors.New("content size exceeded") 310 | } 311 | pasta.Mime = "text/plain" 312 | if pasta.Size == 0 { 313 | bowl.DeletePasta(pasta.Id) 314 | pasta.Id = "" 315 | pasta.DiskFilename = "" 316 | pasta.Token = "" 317 | pasta.ExpireDate = 0 318 | return pasta, public, nil 319 | } 320 | 321 | return pasta, public, nil 322 | } 323 | 324 | /* Delay a request for the given remote if required by spam protection */ 325 | func delayIfRequired(remote string) { 326 | if cf.RequestDelay == 0 { 327 | return 328 | } 329 | address := extractRemoteIP(remote) 330 | now := time.Now().UnixNano() / 1000000 // Timestamp now in milliseconds. This should be fine until 2262 331 | delayMutex.Lock() 332 | delay, ok := delays[address] 333 | delayMutex.Unlock() 334 | if ok { 335 | delta := cf.RequestDelay - (now - delay) 336 | if delta > 0 { 337 | time.Sleep(time.Duration(delta) * time.Millisecond) 338 | } 339 | } 340 | delays[address] = time.Now().UnixNano() / 1000000 // Fresh timestamp 341 | } 342 | 343 | func handlerHead(w http.ResponseWriter, r *http.Request) { 344 | var pasta Pasta 345 | id, err := ExtractPastaId(r.URL.Path) 346 | if err != nil { 347 | goto BadRequest 348 | } 349 | if pasta, err := bowl.GetPasta(id); err != nil { 350 | log.Fatalf("Error getting pasta %s: %s", pasta.Id, err) 351 | goto ServerError 352 | } 353 | if pasta.Id == "" { 354 | goto NotFound 355 | } 356 | 357 | w.Header().Set("Content-Length", strconv.FormatInt(pasta.Size, 10)) 358 | if pasta.Mime != "" { 359 | w.Header().Set("Content-Type", pasta.Mime) 360 | } 361 | if pasta.ExpireDate > 0 { 362 | w.Header().Set("Expires", time.Unix(pasta.ExpireDate, 0).Format("2006-01-02-15:04:05")) 363 | } 364 | w.WriteHeader(200) 365 | fmt.Fprintf(w, "OK") 366 | return 367 | ServerError: 368 | w.WriteHeader(500) 369 | fmt.Fprintf(w, "server error") 370 | return 371 | NotFound: 372 | w.WriteHeader(404) 373 | fmt.Fprintf(w, "pasta not found") 374 | return 375 | BadRequest: 376 | w.WriteHeader(400) 377 | if err == nil { 378 | fmt.Fprintf(w, "bad request") 379 | } else { 380 | fmt.Fprintf(w, "%s", err) 381 | } 382 | return 383 | } 384 | 385 | func handlerPost(w http.ResponseWriter, r *http.Request) { 386 | delayIfRequired(r.RemoteAddr) 387 | pasta, public, err := ReceivePasta(r) 388 | if err != nil { 389 | w.WriteHeader(http.StatusInternalServerError) 390 | fmt.Fprintf(w, "server error") 391 | log.Printf("Receive error: %s", err) 392 | return 393 | } else { 394 | if pasta.Id == "" { 395 | w.WriteHeader(http.StatusBadRequest) 396 | w.Write([]byte("empty pasta")) 397 | } else { 398 | // Save into public pastas, if this is public 399 | if public { 400 | // Store at the beginning 401 | pastas := make([]Pasta, 1) 402 | pastas[0] = pasta 403 | pastas = append(pastas, publicPastas...) 404 | publicPastas = pastas 405 | // Crop to maximum allowed number 406 | if len(publicPastas) > cf.PublicPastas { 407 | publicPastas = publicPastas[len(publicPastas)-cf.PublicPastas:] 408 | } 409 | if err := bowl.WritePublicPastas(publicPastas); err != nil { 410 | log.Printf("Error writing public pastas: %s", err) 411 | } 412 | } 413 | 414 | log.Printf("Received pasta %s (%d bytes) from %s", pasta.Id, pasta.Size, r.RemoteAddr) 415 | w.WriteHeader(http.StatusOK) 416 | url := fmt.Sprintf("%s/%s", cf.BaseUrl, pasta.Id) 417 | // Return format. URL has precedence over http heder 418 | retFormat := r.Header.Get("Return-Format") 419 | retFormats := r.URL.Query()["ret"] 420 | if len(retFormats) > 0 { 421 | retFormat = retFormats[0] 422 | } 423 | if retFormat == "html" { 424 | // Website as return format 425 | fmt.Fprintf(w, "pasta\n") 426 | fmt.Fprintf(w, "\n") 427 | fmt.Fprintf(w, "

pasta

\n") 428 | deleteLink := fmt.Sprintf("%s/delete?id=%s&token=%s", cf.BaseUrl, pasta.Id, pasta.Token) 429 | fmt.Fprintf(w, "

Pasta: %s | 🗑️ Delete
", url, url, deleteLink) 430 | fmt.Fprintf(w, "

")
431 | 				if pasta.ContentFilename != "" {
432 | 					fmt.Fprintf(w, "Filename:           %s\n", pasta.ContentFilename)
433 | 				}
434 | 				if pasta.Mime != "" {
435 | 					fmt.Fprintf(w, "Mime-Type:          %s\n", pasta.Mime)
436 | 				}
437 | 				if pasta.Size > 0 {
438 | 					fmt.Fprintf(w, "Size:               %d B\n", pasta.Size)
439 | 				}
440 | 				if pasta.ExpireDate > 0 {
441 | 					fmt.Fprintf(w, "Expiration:         %s\n", time.Unix(pasta.ExpireDate, 0).Format("2006-01-02-15:04:05"))
442 | 				}
443 | 				if public {
444 | 					fmt.Fprintf(w, "Public:             yes\n")
445 | 				}
446 | 				fmt.Fprintf(w, "Modification token: %s\n
\n", pasta.Token) 447 | fmt.Fprintf(w, "

That was fun! Fancy another one?.

\n", cf.BaseUrl) 448 | fmt.Fprintf(w, "") 449 | } else if retFormat == "json" { 450 | // Dont use json package, the reply is simple enough to build it on-the-fly 451 | reply := fmt.Sprintf("{\"url\":\"%s\",\"token\":\"%s\", \"expire\":%d}", url, pasta.Token, pasta.ExpireDate) 452 | w.Write([]byte(reply)) 453 | } else { 454 | fmt.Fprintf(w, "url: %s\ntoken: %s\n", url, pasta.Token) 455 | } 456 | } 457 | } 458 | } 459 | 460 | func handler(w http.ResponseWriter, r *http.Request) { 461 | var err error 462 | if r.Method == http.MethodGet { 463 | // Check if bin ID is given 464 | id, err := ExtractPastaId(r.URL.Path) 465 | if err != nil { 466 | goto BadRequest 467 | } 468 | if id == "" { 469 | handlerIndex(w, r) 470 | } else { 471 | pasta, err := bowl.GetPasta(id) 472 | if err != nil { 473 | w.WriteHeader(http.StatusInternalServerError) 474 | fmt.Fprintf(w, "Storage error") 475 | log.Fatalf("Storage error: %s", err) 476 | return 477 | } 478 | if pasta.Id == "" { 479 | goto NoSuchPasta 480 | } else { 481 | // Delete expired pasta if present 482 | if pasta.Expired() { 483 | if err = bowl.DeletePasta(pasta.Id); err != nil { 484 | log.Fatalf("Cannot deleted expired pasta %s: %s", pasta.Id, err) 485 | } 486 | goto NoSuchPasta 487 | } 488 | 489 | if err = SendPasta(pasta, w); err != nil { 490 | log.Printf("Error sending pasta %s: %s", pasta.Id, err) 491 | } 492 | } 493 | } 494 | } else if r.Method == http.MethodPost || r.Method == http.MethodPut { 495 | handlerPost(w, r) 496 | } else if r.Method == http.MethodDelete { 497 | delayIfRequired(r.RemoteAddr) 498 | id, err := ExtractPastaId(r.URL.Path) 499 | if err != nil { 500 | goto BadRequest 501 | } 502 | token := takeFirst(r.URL.Query()["token"]) 503 | deletePasta(id, token, w) 504 | } else if r.Method == http.MethodHead { 505 | handlerHead(w, r) 506 | } else { 507 | w.WriteHeader(http.StatusBadRequest) 508 | w.Write([]byte("Unsupported method")) 509 | } 510 | return 511 | NoSuchPasta: 512 | w.WriteHeader(404) 513 | fmt.Fprintf(w, "No pasta\n\nSorry, there is no pasta for this link") 514 | return 515 | BadRequest: 516 | w.WriteHeader(400) 517 | if err == nil { 518 | fmt.Fprintf(w, "bad request") 519 | } else { 520 | fmt.Fprintf(w, "%s", err) 521 | } 522 | } 523 | 524 | func handlerHealth(w http.ResponseWriter, r *http.Request) { 525 | fmt.Fprintf(w, "OK") 526 | } 527 | func handlerHealthJson(w http.ResponseWriter, r *http.Request) { 528 | fmt.Fprintf(w, "{\"status\":\"ok\"}") 529 | } 530 | 531 | func handlerPublic(w http.ResponseWriter, r *http.Request) { 532 | if cf.PublicPastas == 0 { 533 | w.WriteHeader(400) 534 | fmt.Fprintf(w, "public pasta listing is disabled") 535 | return 536 | } 537 | w.WriteHeader(200) 538 | w.Write([]byte("\n\npublic pastas\n\n")) 539 | w.Write([]byte("

public pastas

\n")) 540 | w.Write([]byte("\n")) 541 | w.Write([]byte("\n")) 542 | for _, pasta := range publicPastas { 543 | filename := pasta.ContentFilename 544 | if filename == "" { 545 | filename = pasta.Id 546 | } 547 | w.Write([]byte(fmt.Sprintf("\n", pasta.Id, filename, pasta.Size))) 548 | } 549 | w.Write([]byte("
FilenameSize
%s%d B
\n")) 550 | fmt.Fprintf(w, "

The server presents at most %d public pastas.

\n", cf.PublicPastas) 551 | w.Write([]byte("\n")) 552 | } 553 | 554 | func handlerPublicJson(w http.ResponseWriter, r *http.Request) { 555 | if cf.PublicPastas == 0 { 556 | w.WriteHeader(400) 557 | fmt.Fprintf(w, "public pasta listing is disabled") 558 | return 559 | } 560 | type PublicPasta struct { 561 | Filename string `json:"filename"` 562 | Size int64 `json:"size"` 563 | URL string `json:"url"` 564 | } 565 | pastas := make([]PublicPasta, 0) 566 | for _, pasta := range publicPastas { 567 | filename := pasta.ContentFilename 568 | if filename == "" { 569 | filename = pasta.Id 570 | } 571 | pastas = append(pastas, PublicPasta{Filename: filename, URL: fmt.Sprintf("%s/%s", cf.BaseUrl, pasta.Id), Size: pasta.Size}) 572 | } 573 | buf, err := json.Marshal(pastas) 574 | if err != nil { 575 | log.Printf("json error (public pastas): %s\n", err) 576 | goto ServerError 577 | } 578 | w.WriteHeader(200) 579 | w.Write(buf) 580 | return 581 | ServerError: 582 | w.WriteHeader(500) 583 | w.Write([]byte("Server error")) 584 | } 585 | 586 | func handlerRobots(w http.ResponseWriter, r *http.Request) { 587 | // no robots allowed here 588 | fmt.Fprintf(w, "User-agent: *\nDisallow: /\n") 589 | } 590 | 591 | // Delete pasta 592 | func handlerDelete(w http.ResponseWriter, r *http.Request) { 593 | delayIfRequired(r.RemoteAddr) 594 | id := takeFirst(r.URL.Query()["id"]) 595 | token := takeFirst(r.URL.Query()["token"]) 596 | deletePasta(id, token, w) 597 | } 598 | 599 | func handlerIndex(w http.ResponseWriter, r *http.Request) { 600 | fmt.Fprintf(w, "pasta\n") 601 | fmt.Fprintf(w, "\n") 602 | fmt.Fprintf(w, "

pasta

\n") 603 | fmt.Fprintf(w, "

pasta is a stupid simple pastebin service for easy usage and deployment.

\n") 604 | // List public pastas, if enabled and available 605 | if cf.PublicPastas > 0 && len(publicPastas) > 0 { 606 | fmt.Fprintf(w, "

Public pastas

\n") 607 | fmt.Fprintf(w, "\n") 608 | fmt.Fprintf(w, "\n") 609 | for _, pasta := range publicPastas { 610 | filename := pasta.ContentFilename 611 | if filename == "" { 612 | filename = pasta.Id 613 | } 614 | fmt.Fprintf(w, "\n", pasta.Id, filename, pasta.Size) 615 | } 616 | fmt.Fprintf(w, "
FilenameSize
%s%d B
\n") 617 | if len(publicPastas) == cf.PublicPastas { 618 | fmt.Fprintf(w, "

The server presents at most %d public pastas.

\n", cf.PublicPastas) 619 | } 620 | } 621 | fmt.Fprintf(w, "

Post a new pasta

\n") 622 | fmt.Fprintf(w, "

curl -X POST '%s' --data-binary @FILE

\n", cf.BaseUrl) 623 | if cf.DefaultExpire > 0 { 624 | fmt.Fprintf(w, "

pastas expire by default after %s - Enjoy them while they are fresh!

\n", timeHumanReadable(cf.DefaultExpire)) 625 | } 626 | fmt.Fprintf(w, "

File upload

") 627 | fmt.Fprintf(w, "

Upload your file and make a fresh pasta out of it:

") 628 | fmt.Fprintf(w, "
\n") 629 | fmt.Fprintf(w, "\n") 630 | if cf.PublicPastas > 0 { 631 | fmt.Fprintf(w, " Public\n") 632 | } 633 | fmt.Fprintf(w, "\n") 634 | fmt.Fprintf(w, "
\n") 635 | fmt.Fprintf(w, "

Text paste

") 636 | fmt.Fprintf(w, "

Just paste your contents in the textfield and hit the pasta button below

\n") 637 | fmt.Fprintf(w, "
\n") 638 | fmt.Fprintf(w, "Filename (optional):
\n") 639 | if cf.MaxPastaSize > 0 { 640 | fmt.Fprintf(w, "
\n", cf.MaxPastaSize) 641 | } else { 642 | fmt.Fprintf(w, "
\n") 643 | } 644 | if cf.PublicPastas > 0 { 645 | fmt.Fprintf(w, " Public pasta\n") 646 | } 647 | fmt.Fprintf(w, "\n") 648 | fmt.Fprintf(w, "
\n") 649 | fmt.Fprintf(w, "\n
\n") 650 | fmt.Fprintf(w, "

project page: codeberg.org/grisu48/pasta

\n") 651 | fmt.Fprintf(w, "") 652 | } 653 | 654 | func cleanupThread() { 655 | // Double check this, because I know that I will screw this up at some point in the main routine :-) 656 | if cf.CleanupInterval == 0 { 657 | return 658 | } 659 | for { 660 | duration := time.Now().Unix() 661 | if err := bowl.RemoveExpired(); err != nil { 662 | log.Fatalf("Error while removing expired pastas: %s", err) 663 | } 664 | if cf.RequestDelay > 0 { // Cleanup of the spam protection addresses only if enabled 665 | delayMutex.Lock() 666 | delays = make(map[string]int64) 667 | delayMutex.Unlock() 668 | } 669 | duration = time.Now().Unix() - duration + int64(cf.CleanupInterval) 670 | if duration > 0 { 671 | time.Sleep(time.Duration(cf.CleanupInterval) * time.Second) 672 | } else { 673 | // Don't spam the system, give it at least some time 674 | time.Sleep(time.Second) 675 | } 676 | } 677 | } 678 | 679 | func main() { 680 | cf.SetDefaults() 681 | cf.ReadEnv() 682 | delays = make(map[string]int64) 683 | publicPastas = make([]Pasta, 0) 684 | // Parse program arguments for config 685 | parseCf := ParserConfig{} 686 | parser := argparse.NewParser("pastad", "pasta server") 687 | parseCf.ConfigFile = parser.String("c", "config", &argparse.Options{Default: "", Help: "Set config file"}) 688 | parseCf.BaseURL = parser.String("B", "baseurl", &argparse.Options{Help: "Set base URL for instance"}) 689 | parseCf.PastaDir = parser.String("d", "dir", &argparse.Options{Help: "Set pasta data directory"}) 690 | parseCf.BindAddr = parser.String("b", "bind", &argparse.Options{Help: "Address to bind server to"}) 691 | parseCf.MaxPastaSize = parser.Int("s", "size", &argparse.Options{Help: "Maximum allowed size for a pasta"}) 692 | parseCf.PastaCharacters = parser.Int("n", "chars", &argparse.Options{Help: "Random characters for new pastas"}) 693 | parseCf.MimeTypesFile = parser.String("m", "mime", &argparse.Options{Help: "Define mime types file"}) 694 | parseCf.DefaultExpire = parser.Int("e", "expire", &argparse.Options{Help: "Pasta expire in seconds"}) 695 | parseCf.CleanupInterval = parser.Int("C", "cleanup", &argparse.Options{Help: "Cleanup interval in seconds"}) 696 | parseCf.PublicPastas = parser.Int("p", "public", &argparse.Options{Help: "Number of public pastas to display, if any"}) 697 | if err := parser.Parse(os.Args); err != nil { 698 | fmt.Fprintf(os.Stderr, "%s\n", parser.Usage(err)) 699 | os.Exit(1) 700 | } 701 | log.Printf("Starting pasta server v%s ... \n", VERSION) 702 | configFile := *parseCf.ConfigFile 703 | if configFile != "" { 704 | if FileExists(configFile) { 705 | if _, err := toml.DecodeFile(configFile, &cf); err != nil { 706 | fmt.Printf("Error loading configuration file: %s\n", err) 707 | os.Exit(1) 708 | } 709 | } else { 710 | if err := CreateDefaultConfigfile(configFile); err == nil { 711 | fmt.Fprintf(os.Stderr, "Created default config file '%s'\n", configFile) 712 | } else { 713 | fmt.Fprintf(os.Stderr, "Warning: Cannot create default config file '%s': %s\n", configFile, err) 714 | } 715 | } 716 | } 717 | // Program arguments overwrite config file 718 | parseCf.ApplyTo(&cf) 719 | 720 | // Sanity check 721 | if cf.PastaCharacters <= 0 { 722 | log.Println("Setting pasta characters to default 8 because it was <= 0") 723 | cf.PastaCharacters = 8 724 | } 725 | if cf.PastaCharacters < 8 { 726 | log.Println("Warning: Using less than 8 pasta characters might not be side-effects free") 727 | } 728 | if cf.PastaDir == "" { 729 | cf.PastaDir = "." 730 | } 731 | 732 | // Preparation steps 733 | baseURL, err := ApplyMacros(cf.BaseUrl) 734 | if err != nil { 735 | fmt.Fprintf(os.Stderr, "error applying macros: %s", err) 736 | os.Exit(1) 737 | } 738 | cf.BaseUrl = baseURL 739 | bowl.Directory = cf.PastaDir 740 | os.Mkdir(bowl.Directory, os.ModePerm) 741 | 742 | // Load MIME types file 743 | if cf.MimeTypesFile == "" { 744 | mimeExtensions = make(map[string]string, 0) 745 | } else { 746 | var err error 747 | mimeExtensions, err = loadMimeTypes(cf.MimeTypesFile) 748 | if err != nil { 749 | log.Printf("Warning: Cannot load mime types file '%s': %s", cf.MimeTypesFile, err) 750 | } else { 751 | log.Printf("Loaded %d mime types", len(mimeExtensions)) 752 | } 753 | } 754 | 755 | // Load public pastas 756 | if cf.PublicPastas > 0 { 757 | pastas, err := bowl.GetPublicPastas() 758 | if err != nil { 759 | log.Printf("Error loading public pastas: %s", err) 760 | } else { 761 | // Crop if necessary 762 | if len(pastas) > cf.PublicPastas { 763 | pastas = pastas[len(pastas)-cf.PublicPastas:] 764 | bowl.WritePublicPastaIDs(pastas) 765 | } 766 | for _, id := range pastas { 767 | if id == "" { 768 | continue 769 | } 770 | pasta, err := bowl.GetPasta(id) 771 | if err == nil && pasta.Id != "" { 772 | publicPastas = append(publicPastas, pasta) 773 | } 774 | } 775 | log.Printf("Loaded %d public pastas", len(publicPastas)) 776 | } 777 | } 778 | 779 | // Start cleanup thread 780 | if cf.CleanupInterval > 0 { 781 | go cleanupThread() 782 | } 783 | 784 | // Setup webserver 785 | http.HandleFunc("/", handler) 786 | http.HandleFunc("/health", handlerHealth) 787 | http.HandleFunc("/health.json", handlerHealthJson) 788 | http.HandleFunc("/public", handlerPublic) 789 | http.HandleFunc("/public.json", handlerPublicJson) 790 | http.HandleFunc("/delete", handlerDelete) 791 | http.HandleFunc("/robots.txt", handlerRobots) 792 | log.Printf("Serving http://%s", cf.BindAddr) 793 | log.Fatal(http.ListenAndServe(cf.BindAddr, nil)) 794 | } 795 | -------------------------------------------------------------------------------- /cmd/pastad/storage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type Pasta struct { 17 | Id string // id of the pasta 18 | Token string // modification token 19 | DiskFilename string // filename for the pasta on the disk 20 | ContentFilename string // Filename of the content 21 | ExpireDate int64 // Unix() date when it will expire 22 | Size int64 // file size 23 | Mime string // mime type 24 | } 25 | 26 | func (pasta *Pasta) Expired() bool { 27 | if pasta.ExpireDate == 0 { 28 | return false 29 | } else { 30 | return time.Now().Unix() > pasta.ExpireDate 31 | } 32 | } 33 | 34 | func randBytes(n int) []byte { 35 | buf := make([]byte, n) 36 | i, err := rand.Read(buf) 37 | if err != nil { 38 | panic(err) 39 | } 40 | if i < n { 41 | panic(fmt.Errorf("random generator empty")) 42 | } 43 | return buf 44 | } 45 | 46 | func randInt() int { 47 | buf := randBytes(4) 48 | ret := 0 49 | for i := 0; i < 4; i++ { 50 | ret += int(buf[i]) << (i * 8) 51 | } 52 | return ret 53 | } 54 | 55 | func RandomString(n int) string { 56 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 57 | b := make([]rune, n) 58 | for i := range b { 59 | 60 | b[i] = letterRunes[randInt()%len(letterRunes)] 61 | } 62 | return string(b) 63 | } 64 | 65 | func FileExists(filename string) bool { 66 | _, err := os.Stat(filename) 67 | if err != nil { 68 | return false 69 | } 70 | return !os.IsNotExist(err) 71 | } 72 | 73 | func strBool(val string, def bool) bool { 74 | val = strings.TrimSpace(val) 75 | val = strings.ToLower(val) 76 | 77 | if val == "true" { 78 | return true 79 | } else if val == "yes" { 80 | return true 81 | } else if val == "on" { 82 | return true 83 | } else if val == "1" { 84 | return true 85 | } else if val == "false" { 86 | return false 87 | } else if val == "no" { 88 | return false 89 | } else if val == "off" { 90 | return false 91 | } else if val == "0" { 92 | return false 93 | } 94 | 95 | return def 96 | } 97 | 98 | /* PastaBowl is the main storage instance */ 99 | type PastaBowl struct { 100 | Directory string // Directory where the pastas are 101 | } 102 | 103 | func (bowl *PastaBowl) filename(id string) string { 104 | return fmt.Sprintf("%s/%s", bowl.Directory, id) 105 | } 106 | 107 | func (bowl *PastaBowl) Exists(id string) bool { 108 | return FileExists(bowl.filename(id)) 109 | } 110 | 111 | /** Check for expired pastas and delete them */ 112 | func (bowl *PastaBowl) RemoveExpired() error { 113 | files, err := ioutil.ReadDir(bowl.Directory) 114 | if err != nil { 115 | return err 116 | } 117 | for _, file := range files { 118 | if file.Size() == 0 { 119 | continue 120 | } 121 | pasta, err := bowl.GetPasta(file.Name()) 122 | if err != nil { 123 | return err 124 | } 125 | if pasta.Expired() { 126 | if err := bowl.DeletePasta(pasta.Id); err != nil { 127 | return err 128 | } 129 | } 130 | } 131 | return nil 132 | } 133 | 134 | // get pasta metadata 135 | func (bowl *PastaBowl) GetPasta(id string) (Pasta, error) { 136 | pasta := Pasta{Id: "", DiskFilename: bowl.filename(id)} 137 | stat, err := os.Stat(bowl.filename(id)) 138 | if err != nil { 139 | // Does not exists results in empty pasta result 140 | if !os.IsExist(err) { 141 | return pasta, nil 142 | } 143 | return pasta, err 144 | } 145 | pasta.Size = stat.Size() 146 | file, err := os.OpenFile(pasta.DiskFilename, os.O_RDONLY, 0400) 147 | if err != nil { 148 | return pasta, err 149 | } 150 | defer file.Close() 151 | // Read metadata (until "---") 152 | scanner := bufio.NewScanner(file) 153 | for scanner.Scan() { 154 | if err = scanner.Err(); err != nil { 155 | return pasta, err 156 | } 157 | line := scanner.Text() 158 | pasta.Size -= int64(len(line) + 1) 159 | if line == "---" { 160 | break 161 | } 162 | // Parse metadata (name: value) 163 | i := strings.Index(line, ":") 164 | if i <= 0 { 165 | continue 166 | } 167 | name, value := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]) 168 | if name == "token" { 169 | pasta.Token = value 170 | } else if name == "expire" { 171 | pasta.ExpireDate, _ = strconv.ParseInt(value, 10, 64) 172 | } else if name == "mime" { 173 | pasta.Mime = value 174 | } else if name == "filename" { 175 | pasta.ContentFilename = value 176 | } 177 | 178 | } 179 | // All good 180 | pasta.Id = id 181 | return pasta, nil 182 | } 183 | 184 | func (bowl *PastaBowl) getPastaFile(id string, flag int) (*os.File, error) { 185 | filename := bowl.filename(id) 186 | file, err := os.OpenFile(filename, flag, 0640) 187 | if err != nil { 188 | return nil, err 189 | } 190 | buf := make([]byte, 1) 191 | c := 0 // Counter 192 | for { 193 | n, err := file.Read(buf) 194 | if err != nil { 195 | if err == io.EOF { 196 | file.Close() 197 | return nil, errors.New("unexpected end of block") 198 | } 199 | file.Close() 200 | return nil, errors.New("unexpected end of block") 201 | } 202 | if n == 0 { 203 | continue 204 | } 205 | if buf[0] == '-' { 206 | c++ 207 | } else if buf[0] == '\n' { 208 | if c == 3 { 209 | return file, nil 210 | } 211 | c = 0 212 | } else { 213 | c = 0 214 | } 215 | } 216 | } 217 | 218 | // Get the file instance to the pasta content (read-only) 219 | func (bowl *PastaBowl) GetPastaReader(id string) (*os.File, error) { 220 | return bowl.getPastaFile(id, os.O_RDONLY) 221 | } 222 | 223 | // Get the file instance to the pasta content (read-only) 224 | func (bowl *PastaBowl) GetPastaWriter(id string) (*os.File, error) { 225 | return bowl.getPastaFile(id, os.O_RDWR) 226 | } 227 | 228 | // Prepare a pasta file to be written. Id and Token will be set, if not already done 229 | func (bowl *PastaBowl) InsertPasta(pasta *Pasta) error { 230 | if pasta.Id == "" { 231 | // TODO: Use crypto rand 232 | pasta.Id = bowl.GenerateRandomBinId(8) // Use default length here 233 | } 234 | if pasta.Token == "" { 235 | // TODO: Use crypto rand 236 | pasta.Token = RandomString(16) 237 | } 238 | pasta.DiskFilename = bowl.filename(pasta.Id) 239 | file, err := os.OpenFile(pasta.DiskFilename, os.O_RDWR|os.O_CREATE, 0640) 240 | if err != nil { 241 | return err 242 | } 243 | defer file.Close() 244 | if _, err := file.Write([]byte(fmt.Sprintf("token:%s\n", pasta.Token))); err != nil { 245 | return err 246 | } 247 | if pasta.ExpireDate > 0 { 248 | if _, err := file.Write([]byte(fmt.Sprintf("expire:%d\n", pasta.ExpireDate))); err != nil { 249 | return err 250 | } 251 | } 252 | if pasta.Mime != "" { 253 | if _, err := file.Write([]byte(fmt.Sprintf("mime:%s\n", pasta.Mime))); err != nil { 254 | return err 255 | } 256 | } 257 | if pasta.ContentFilename != "" { 258 | if _, err := file.Write([]byte(fmt.Sprintf("filename:%s\n", pasta.ContentFilename))); err != nil { 259 | return err 260 | } 261 | } 262 | 263 | if _, err := file.Write([]byte("---\n")); err != nil { 264 | return err 265 | } 266 | return file.Sync() 267 | } 268 | 269 | func (bowl *PastaBowl) DeletePasta(id string) error { 270 | if !bowl.Exists(id) { 271 | return nil 272 | } 273 | return os.Remove(bowl.filename(id)) 274 | } 275 | 276 | func (bowl *PastaBowl) GenerateRandomBinId(n int) string { 277 | for { 278 | id := RandomString(n) 279 | if !bowl.Exists(id) { 280 | return id 281 | } 282 | } 283 | } 284 | 285 | // GetPublicPastas returns a list of Public pasta IDs, stored in the bowl 286 | func (bowl *PastaBowl) GetPublicPastas() ([]string, error) { 287 | ret := make([]string, 0) 288 | filename := fmt.Sprintf("%s/_public", bowl.Directory) 289 | if !FileExists(filename) { 290 | return ret, nil 291 | } 292 | 293 | file, err := os.OpenFile(filename, os.O_RDONLY, 0400) 294 | if err != nil { 295 | return ret, err 296 | } 297 | defer file.Close() 298 | // Read public pastas, one by one 299 | scanner := bufio.NewScanner(file) 300 | for scanner.Scan() { 301 | line := strings.TrimSpace(scanner.Text()) 302 | if line == "" { 303 | continue 304 | } 305 | ret = append(ret, line) 306 | } 307 | return ret, scanner.Err() 308 | } 309 | 310 | // WritePublicPastas writes a list of public pastas to the public file 311 | func (bowl *PastaBowl) WritePublicPastaIDs(ids []string) error { 312 | filename := fmt.Sprintf("%s/_public", bowl.Directory) 313 | file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0640) 314 | if err != nil { 315 | return err 316 | } 317 | defer file.Close() 318 | for _, id := range ids { 319 | file.Write([]byte(fmt.Sprintf("%s\n", id))) 320 | } 321 | return file.Sync() 322 | } 323 | 324 | func (bowl *PastaBowl) WritePublicPastas(pastas []Pasta) error { 325 | ids := make([]string, 0) 326 | for _, pasta := range pastas { 327 | ids = append(ids, pasta.Id) 328 | } 329 | return bowl.WritePublicPastaIDs(ids) 330 | } 331 | -------------------------------------------------------------------------------- /cmd/pastad/storage__test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "math/rand" 6 | "os" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | var testBowl PastaBowl 12 | 13 | func TestMain(m *testing.M) { 14 | // Initialisation 15 | rand.Seed(time.Now().UnixNano()) 16 | testBowl.Directory = "pasta_test" 17 | os.Mkdir(testBowl.Directory, os.ModePerm) 18 | defer os.RemoveAll(testBowl.Directory) 19 | // Run tests 20 | ret := m.Run() 21 | os.Exit(ret) 22 | } 23 | 24 | func TestMetadata(t *testing.T) { 25 | var err error 26 | var pasta, p1, p2, p3 Pasta 27 | 28 | p1.Mime = "text/plain" 29 | p1.ExpireDate = 0 30 | if err = testBowl.InsertPasta(&p1); err != nil { 31 | t.Fatalf("Error inserting pasta 1: %s", err) 32 | return 33 | } 34 | if p1.Id == "" { 35 | t.Fatal("Pasta 1 id not set") 36 | return 37 | } 38 | if p1.Token == "" { 39 | t.Fatal("Pasta 1 id not set") 40 | return 41 | } 42 | p2.Mime = "application/json" 43 | p2.ExpireDate = time.Now().Unix() + 10000 44 | if err = testBowl.InsertPasta(&p2); err != nil { 45 | t.Fatalf("Error inserting pasta 2: %s", err) 46 | return 47 | } 48 | // Insert pasta with given ID and Token 49 | p3Id := testBowl.GenerateRandomBinId(12) 50 | p3Token := RandomString(20) 51 | p3.Id = p3Id 52 | p3.Token = p3Token 53 | p3.Mime = "text/rtf" 54 | p3.ExpireDate = time.Now().Unix() + 20000 55 | if err = testBowl.InsertPasta(&p3); err != nil { 56 | t.Fatalf("Error inserting pasta 3: %s", err) 57 | return 58 | } 59 | if p3.Id != p3Id { 60 | t.Fatal("Pasta 3 id mismatch") 61 | return 62 | } 63 | if p3.Token != p3Token { 64 | t.Fatal("Pasta 3 id mismatch") 65 | return 66 | } 67 | 68 | pasta, err = testBowl.GetPasta(p1.Id) 69 | if err != nil { 70 | t.Fatalf("Error getting pasta 1: %s", err) 71 | return 72 | } 73 | if pasta != p1 { 74 | t.Fatal("Pasta 1 mismatch") 75 | return 76 | } 77 | pasta, err = testBowl.GetPasta(p2.Id) 78 | if err != nil { 79 | t.Fatalf("Error getting pasta 2: %s", err) 80 | return 81 | } 82 | if pasta != p2 { 83 | t.Fatal("Pasta 2 mismatch") 84 | return 85 | } 86 | pasta, err = testBowl.GetPasta(p3.Id) 87 | if err != nil { 88 | t.Fatalf("Error getting pasta 3: %s", err) 89 | return 90 | } 91 | if pasta != p3 { 92 | t.Fatal("Pasta 3 mismatch") 93 | return 94 | } 95 | 96 | if err = testBowl.DeletePasta(p1.Id); err != nil { 97 | t.Fatalf("Error deleting pasta 1: %s", err) 98 | } 99 | pasta, err = testBowl.GetPasta(p1.Id) 100 | if err != nil { 101 | t.Fatalf("Error getting pasta 1 (after delete): %s", err) 102 | return 103 | } 104 | if pasta.Id != "" { 105 | t.Fatal("Pasta 1 exists after delete") 106 | return 107 | } 108 | // Ensure pasta 2 and 3 are not affected if we delete pasta 1 109 | pasta, err = testBowl.GetPasta(p2.Id) 110 | if err != nil { 111 | t.Fatalf("Error getting pasta 2 after deleting pasta 1: %s", err) 112 | return 113 | } 114 | if pasta != p2 { 115 | t.Fatal("Pasta 2 mismatch after deleting pasta 1") 116 | return 117 | } 118 | pasta, err = testBowl.GetPasta(p3.Id) 119 | if err != nil { 120 | t.Fatalf("Error getting pasta 3 after deleting pasta 1: %s", err) 121 | return 122 | } 123 | if pasta != p3 { 124 | t.Fatal("Pasta 3 mismatch after deleteing pasta 1") 125 | return 126 | } 127 | // Delete also pasta 2 128 | if err = testBowl.DeletePasta(p2.Id); err != nil { 129 | t.Fatalf("Error deleting pasta 2: %s", err) 130 | } 131 | pasta, err = testBowl.GetPasta(p2.Id) 132 | if err != nil { 133 | t.Fatalf("Error getting pasta 2 (after delete): %s", err) 134 | return 135 | } 136 | if pasta.Id != "" { 137 | t.Fatal("Pasta 2 exists after delete") 138 | return 139 | } 140 | pasta, err = testBowl.GetPasta(p3.Id) 141 | if err != nil { 142 | t.Fatalf("Error getting pasta 3 after deleting pasta 2: %s", err) 143 | return 144 | } 145 | if pasta != p3 { 146 | t.Fatal("Pasta 3 mismatch after deleting pasta 2") 147 | return 148 | } 149 | } 150 | 151 | func TestBlobs(t *testing.T) { 152 | var err error 153 | var p1, p2 Pasta 154 | 155 | // Contents 156 | testString1 := RandomString(4096 * 8) 157 | testString2 := RandomString(4096 * 8) 158 | 159 | if err = testBowl.InsertPasta(&p1); err != nil { 160 | t.Fatalf("Error inserting pasta 1: %s", err) 161 | return 162 | } 163 | file, err := testBowl.GetPastaWriter(p1.Id) 164 | if err != nil { 165 | t.Fatalf("Error getting pasta file 1: %s", err) 166 | return 167 | } 168 | defer file.Close() 169 | if _, err = file.Write([]byte(testString1)); err != nil { 170 | t.Fatalf("Error writing to pasta file 1: %s", err) 171 | return 172 | } 173 | if err = file.Close(); err != nil { 174 | t.Fatalf("Error closing pasta file 1: %s", err) 175 | return 176 | } 177 | if err = testBowl.InsertPasta(&p2); err != nil { 178 | t.Fatalf("Error inserting pasta 2: %s", err) 179 | return 180 | } 181 | file, err = testBowl.GetPastaWriter(p2.Id) 182 | if err != nil { 183 | t.Fatalf("Error getting pasta file 2: %s", err) 184 | return 185 | } 186 | defer file.Close() 187 | if _, err = file.Write([]byte(testString2)); err != nil { 188 | t.Fatalf("Error writing to pasta file 2: %s", err) 189 | return 190 | } 191 | if err = file.Close(); err != nil { 192 | t.Fatalf("Error closing pasta file 2: %s", err) 193 | return 194 | } 195 | // Fetch contents now 196 | file, err = testBowl.GetPastaReader(p1.Id) 197 | if err != nil { 198 | t.Fatalf("Error getting pasta reader 1: %s", err) 199 | return 200 | } 201 | buf, err := ioutil.ReadAll(file) 202 | file.Close() 203 | if err != nil { 204 | t.Fatalf("Error reading pasta 1: %s", err) 205 | return 206 | } 207 | if testString1 != string(buf) { 208 | t.Fatal("Mismatch: pasta 1 contents") 209 | t.Logf("Bytes: Read %d, Expected %d", len(buf), len(([]byte(testString1)))) 210 | return 211 | } 212 | // Same for pasta 2 213 | file, err = testBowl.GetPastaReader(p2.Id) 214 | if err != nil { 215 | t.Fatalf("Error getting pasta reader 2: %s", err) 216 | return 217 | } 218 | buf, err = ioutil.ReadAll(file) 219 | file.Close() 220 | if err != nil { 221 | t.Fatalf("Error reading pasta 2: %s", err) 222 | return 223 | } 224 | if testString2 != string(buf) { 225 | t.Fatal("Mismatch: pasta 2 contents") 226 | t.Logf("Bytes: Read %d, Expected %d", len(buf), len(([]byte(testString2)))) 227 | return 228 | } 229 | 230 | // Check if pasta 1 can be deleted and the contents of pasta 2 are still OK afterwards 231 | if err = testBowl.DeletePasta(p1.Id); err != nil { 232 | t.Fatalf("Error deleting pasta 1: %s", err) 233 | } 234 | file, err = testBowl.GetPastaReader(p2.Id) 235 | if err != nil { 236 | t.Fatalf("Error getting pasta reader 2: %s", err) 237 | return 238 | } 239 | buf, err = ioutil.ReadAll(file) 240 | file.Close() 241 | if err != nil { 242 | t.Fatalf("Error reading pasta 2: %s", err) 243 | return 244 | } 245 | if testString2 != string(buf) { 246 | t.Fatal("Mismatch: pasta 2 contents") 247 | t.Logf("Bytes: Read %d, Expected %d", len(buf), len(([]byte(testString2)))) 248 | return 249 | } 250 | 251 | } 252 | -------------------------------------------------------------------------------- /cmd/pastad/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | // getenv reads a given environmental variable and returns it's value if present or defval if not present or empty 12 | func getenv(key string, defval string) string { 13 | val := os.Getenv(key) 14 | if val == "" { 15 | return defval 16 | } 17 | return val 18 | } 19 | 20 | // getenv reads a given environmental variable as integer and returns it's value if present or defval if not present or empty 21 | func getenv_i(key string, defval int) int { 22 | val := os.Getenv(key) 23 | if val == "" { 24 | return defval 25 | } 26 | if i32, err := strconv.Atoi(val); err != nil { 27 | return defval 28 | } else { 29 | return i32 30 | } 31 | } 32 | 33 | // getenv reads a given environmental variable as integer and returns it's value if present or defval if not present or empty 34 | func getenv_i64(key string, defval int64) int64 { 35 | val := os.Getenv(key) 36 | if val == "" { 37 | return defval 38 | } 39 | if i64, err := strconv.ParseInt(val, 10, 64); err != nil { 40 | return defval 41 | } else { 42 | return i64 43 | } 44 | } 45 | 46 | func isAlphaNumeric(c rune) bool { 47 | return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') 48 | } 49 | 50 | func containsOnlyAlphaNumeric(input string) bool { 51 | for _, c := range input { 52 | if !isAlphaNumeric(c) { 53 | return false 54 | } 55 | } 56 | return true 57 | } 58 | 59 | func removeNonAlphaNumeric(input string) string { 60 | ret := "" 61 | for _, c := range input { 62 | if isAlphaNumeric(c) { 63 | ret += string(c) 64 | } 65 | } 66 | return ret 67 | } 68 | 69 | func ExtractPastaId(path string) (string, error) { 70 | var id string 71 | i := strings.LastIndex(path, "/") 72 | if i < 0 { 73 | id = path 74 | } else { 75 | id = path[i+1:] 76 | } 77 | if !containsOnlyAlphaNumeric(id) { 78 | return "", fmt.Errorf("invalid id") 79 | } 80 | return id, nil 81 | } 82 | 83 | /* Load MIME types file. MIME types file is a simple text file that describes mime types based on file extenstions. 84 | * The format of the file is 85 | * EXTENSION = MIMETYPE 86 | */ 87 | func loadMimeTypes(filename string) (map[string]string, error) { 88 | ret := make(map[string]string, 0) 89 | 90 | file, err := os.OpenFile(filename, os.O_RDONLY, 0400) 91 | if err != nil { 92 | return ret, err 93 | } 94 | defer file.Close() 95 | scanner := bufio.NewScanner(file) 96 | for scanner.Scan() { 97 | line := strings.TrimSpace(scanner.Text()) 98 | if line == "" || line[0] == '#' { 99 | continue 100 | } 101 | i := strings.Index(line, "=") 102 | if i < 0 { 103 | continue 104 | } 105 | name, value := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]) 106 | if name != "" && value != "" { 107 | ret[name] = value 108 | } 109 | } 110 | 111 | return ret, scanner.Err() 112 | } 113 | 114 | func takeFirst(arr []string) string { 115 | if len(arr) == 0 { 116 | return "" 117 | } 118 | return arr[0] 119 | } 120 | 121 | /* try to determine the mime type by file extension. Returns empty string on failure */ 122 | func mimeByFilename(filename string) string { 123 | i := strings.LastIndex(filename, ".") 124 | if i < 0 { 125 | return "" 126 | } 127 | extension := filename[i+1:] 128 | if mime, ok := mimeExtensions[extension]; ok { 129 | return mime 130 | } 131 | return "" 132 | } 133 | 134 | /* Extract the remote IP address of the given remote 135 | * The remote is expected to come from http.Request and contain the IP address plus the port */ 136 | func extractRemoteIP(remote string) string { 137 | // Check if IPv6 138 | i := strings.Index(remote, "[") 139 | if i >= 0 { 140 | j := strings.Index(remote, "]") 141 | if j <= i { 142 | return remote 143 | } 144 | return remote[i+1 : j] 145 | } 146 | i = strings.Index(remote, ":") 147 | if i > 0 { 148 | return remote[:i] 149 | } 150 | return remote 151 | } 152 | func timeHumanReadable(timestamp int64) string { 153 | if timestamp < 60 { 154 | return fmt.Sprintf("%d s", timestamp) 155 | } 156 | 157 | minutes := timestamp / 60 158 | seconds := timestamp - (minutes * 60) 159 | if minutes < 60 { 160 | return fmt.Sprintf("%d:%d min", minutes, seconds) 161 | } 162 | 163 | hours := minutes / 60 164 | minutes -= hours * 60 165 | if hours < 24 { 166 | return fmt.Sprintf("%d s", hours) 167 | } 168 | 169 | days := hours / 24 170 | hours -= days * 24 171 | if days > 365 { 172 | years := float32(days) / 365.0 173 | return fmt.Sprintf("%.2f years", years) 174 | } else if days > 28 { 175 | weeks := days / 7 176 | if weeks > 4 { 177 | months := days / 30 178 | return fmt.Sprintf("%d months", months) 179 | } 180 | return fmt.Sprintf("%d weeks", weeks) 181 | } else { 182 | return fmt.Sprintf("%d days", days) 183 | } 184 | } 185 | 186 | /* Apply custom macros in the given string and return the result. The following macros are supports: 187 | * `$hostname` - Replace with the system hostname 188 | */ 189 | func ApplyMacros(txt string) (string, error) { 190 | if strings.Contains(txt, "$hostname") { 191 | hostname, err := os.Hostname() 192 | if err != nil { 193 | return "", err 194 | } 195 | txt = strings.ReplaceAll(txt, "$hostname", hostname) 196 | } 197 | return txt, nil 198 | } 199 | -------------------------------------------------------------------------------- /docs/build.md: -------------------------------------------------------------------------------- 1 | # Building 2 | 3 | ## Build and run from source 4 | 5 | Use the provided `Makefile` commands: 6 | 7 | make # all 8 | 9 | make pastad # Server 10 | make pasta # Client 11 | 12 | make static # build static binaries (for release) 13 | 14 | ## Build docker image 15 | 16 | make docker 17 | 18 | Or manually: 19 | 20 | docker build . -t feldspaten.org/pasta # Build docker container 21 | 22 | Create or run the container with 23 | 24 | docker container create --name pasta -p 8199:8199 -v ABSOLUTE_PATH_TO_DATA_DIR:/data feldspaten.org/pasta 25 | docker container run --name pasta -p 8199:8199 -v ABSOLUTE_PATH_TO_DATA_DIR:/data feldspaten.org/pasta 26 | 27 | The container needs a `data` directory with a valid `pastad.toml` (See the [example file](pastad.toml.example), otherwise default values will be used). -------------------------------------------------------------------------------- /docs/cloud-init.yaml.example: -------------------------------------------------------------------------------- 1 | ssh_authorized_keys: 2 | - ssh-rsa ... 3 | mounts: 4 | - ["/dev/sdb1", "/data", "ext4", ""] 5 | write_files: 6 | - path: /mnt/pastad.toml 7 | permissions: "0755" 8 | owner: root 9 | content: | 10 | BaseURL = "https://pasta.domain.com" # replace with your hostname 11 | PastaDir = "pastas" # absolute or relative path to the pastas 12 | BindAddress = ":8199" # server bind address 13 | MaxPastaSize = 26214400 # max allowed pasta size - 5 MB 14 | PastaCharacters = 8 # Number of characters for pasta id 15 | Expire = 2592000 # Default expire in seconds (1 Month) 16 | Cleanup = 3600 # Cleanup interval in seconds (1 hour) 17 | runcmd: 18 | - sudo wget https://raw.githubusercontent.com/grisu48/pasta/main/mime.types -O /data/mime.types 19 | - sudo cp /mnt/pastad.toml /data/pastad.toml 20 | rancher: 21 | network: 22 | dns: 23 | nameservers: 24 | - 8.8.8.8 25 | - 1.1.1.1 26 | interfaces: 27 | eth0: 28 | addresses: 29 | - 192.0.2.2/24 30 | - 2001:db8::2/64 31 | gateway: 192.0.2.1 32 | gateway_ipv6: 2001:db8::1 33 | mtu: 1500 34 | dhcp: false 35 | services: 36 | past: 37 | image: grisu48/pasta 38 | volumes: 39 | - /data:/data 40 | ports: 41 | - "80:8199" 42 | restart: always 43 | 44 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The easiest way is to run `pasta` as a container service or get the pre-build binaries from the releases within this repository. 4 | 5 | If you prefer the native applications, checkout the sections below. 6 | 7 | ## Install on openSUSE 8 | 9 | openSUSE packages are provided at [build.opensuse.org](https://build.opensuse.org/package/show/home%3Aph03nix%3Atools/pasta). 10 | To install follow the instructions from [software.opensuse.org](https://software.opensuse.org/download/package?package=pasta&project=home%3Aph03nix%3Atools) or the following snippet: 11 | 12 | # Tumbleweed 13 | zypper addrepo zypper addrepo https://download.opensuse.org/repositories/home:ph03nix:tools/openSUSE_Tumbleweed/home:ph03nix:tools.repo 14 | zypper refresh && zypper install pasta 15 | 16 | ## RancherOS 17 | 18 | Let's assume we have `/dev/sda` for the system and `/dev/sdb` for data. 19 | 20 | * Prepare persistent storage for data 21 | * Install the system with given [`cloud-init.yaml`](cloud-init.yaml.example) to system storage 22 | * Configure your proxy and enojoy! 23 | 24 | ```bash 25 | $ sudo parted /dev/sdb 26 | # mktable - gpt - mkpart - 1 - 0% - 100% 27 | $ sudo mkfs.ext4 /dev/sdb1 28 | $ sudo ros install -d /dev/sda -c cloud-init.yaml 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # pasta documentation 2 | 3 | This folder contains auxilliary documentation for pasta. 4 | Checkout one of the following sections. 5 | 6 | * [Getting started guides](getting-started.md) 7 | * [Build instructions](build.md) -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grisu48/pasta 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/akamensky/argparse v1.4.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= 4 | github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= 5 | -------------------------------------------------------------------------------- /mime.types: -------------------------------------------------------------------------------- 1 | # Known mime types based on file extension 2 | bmp = image/bmp 3 | bz = application/x-bzip 4 | bz2 = application/x-bzip2 5 | csh = application/x-csh 6 | css = text/csvv 7 | csv = text/csv 8 | doc = application/msword 9 | docx = application/vnd.openxmlformats-officedocument.wordprocessingml.document 10 | epub = application/epub+zip 11 | gz = application/gzip 12 | gif = image/gif 13 | htm = text/html 14 | html = text/html 15 | ics = text/calendar 16 | jar = application/java-archive 17 | jpg = image/jpeg 18 | jpeg = image/jpeg 19 | js = text/javascript 20 | json = application/json 21 | mp3 = audio/mpeg 22 | mpg = audio/mpeg 23 | mpeg = audio/mpeg 24 | ods = application/vnd.oasis.opendocument.presentation 25 | otd = application/vnd.oasis.opendocument.spreadsheet 26 | otd = application/vnd.oasis.opendocument.text 27 | oga = audio/ogg 28 | ogv = audio/ogg 29 | opus = audio/opus 30 | otf = font/otf 31 | png = image/png 32 | pdf = application/pdf 33 | php = application/x-httpd-php 34 | ppt = application/vnd.ms-powerpoint 35 | pptx = application/vnd.openxmlformats-officedocument.presentationml.presentation 36 | rat = application/vnd.rar 37 | rtf = application/rtf 38 | sh = application/x-sh 39 | svg = image/svg+xml 40 | tar = application/x-tar 41 | tif = image/tiff 42 | tiff = image/tiff 43 | ts = video/mp2t 44 | ttf = font/ttf 45 | txt = text/plain 46 | wav = audio/wav 47 | weba = audio/webm 48 | webm = video/webm 49 | webp = image/webp 50 | woff = font/woff 51 | woff2 = font/woff2 52 | xhtml = application/xhtml+xml 53 | xls = application/vnd.ms-excel 54 | xlsx = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 55 | xml = text/xml 56 | xz = application/x-xz 57 | zip = application/zip 58 | 7z = application/x-7z-compressed 59 | -------------------------------------------------------------------------------- /pasta.toml.example: -------------------------------------------------------------------------------- 1 | # Keep in mind to escape string. This is TOML not an ini file! 2 | # Place this file in ~/.pasta.toml 3 | 4 | RemoteHost = "http://localhost:8199" 5 | 6 | # Example for a remote with one alias 7 | # aliases can be given to `pasta` as a remote argument and will be 8 | # completed to the given url 9 | [[Remote]] 10 | url = "http://localhost:8199" 11 | alias = "localhost" 12 | 13 | # Example of a remote with multiple aliases 14 | [[Remote]] 15 | url = ""http://localhost:8200" 16 | alias = "localhost2" # one alias 17 | aliases = ["local2", "loc2"] # more aliases 18 | -------------------------------------------------------------------------------- /pastad.toml.example: -------------------------------------------------------------------------------- 1 | BaseURL = "http://localhost:8199" # base URL as used within pasta 2 | BindAddress = ":8199" # bind address 3 | PastaDir = "pastas" # absolute or relative path to the pastas data directory 4 | MaxPastaSize = 5242880 # max allowed pasta size (5 MiB) 5 | PastaCharacters = 8 # Number of characters for pasta id 6 | Expire = 2592000 # Default expire in seconds (1 Month) 7 | Cleanup = 3600 # Cleanup interval in seconds (1 hour) 8 | RequestDelay = 2000 # Milliseconds between POST/DELETE requests per host 9 | PublicPastas = 0 # Number of public pastas to display or 0 to disable public display (default) 10 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Summary: Function test for pasta & pastad 3 | 4 | PASTAS=~/.pastas.dat # pasta client dat file 5 | PASTAS_TEMP="" # temp file, if present 6 | 7 | function cleanup() { 8 | set +e 9 | # restore old pasta client file 10 | if [[ $PASTAS_TEMP != "" ]]; then 11 | mv "$PASTAS_TEMP" "$PASTAS" 12 | fi 13 | rm -f testfile 14 | rm -f testfile2 15 | rm -f rm 16 | kill %1 17 | rm -rf pasta_test 18 | rm -f pasta.json 19 | rm -f test_config.toml 20 | } 21 | 22 | trap cleanup EXIT 23 | 24 | ## Preparation: Safe old pastas.dat, if existing 25 | if [[ -s $PASTAS ]]; then 26 | PASTAS_TEMP=`mktemp` 27 | mv "$PASTAS" "$PASTAS_TEMP" 28 | fi 29 | 30 | ## Setup pasta server 31 | ../pastad -c pastad.toml -m ../mime.types -B http://127.0.0.1:8200 -b 127.0.0.1:8200 & 32 | sleep 2 33 | 34 | ## Push a testfile 35 | echo "Testfile 123" > testfile 36 | link=`../pasta -r http://127.0.0.1:8200 < testfile` 37 | curl --fail -o testfile2 $link 38 | diff testfile testfile2 39 | echo "Testfile matches" 40 | echo "Testfile 123456789" > testfile 41 | link=`../pasta -r http://127.0.0.1:8200 < testfile` 42 | curl --fail -o testfile2 $link 43 | diff testfile testfile2 44 | echo "Testfile 2 matches" 45 | # Test also sending via curl 46 | url=`curl --fail -X POST http://127.0.0.1:8200/ --data-binary @testfile | grep -Eo 'http://.*'` 47 | echo "curl stored as $url" 48 | curl --fail -o testfile3 "$url" 49 | diff testfile testfile3 50 | echo "Testfile 3 matches" 51 | # Test the POST form 52 | echo -n "testpasta" > testfile4 53 | url=`curl --fail -X POST "http://127.0.0.1:8200?input=form&content=testpasta" | grep -Eo 'http://.*'` 54 | curl --fail -o testfile5 "$url" 55 | diff testfile4 testfile5 56 | # Test different format in link 57 | curl --fail -X POST http://127.0.0.1:8200?ret=json --data-binary @testfile 58 | 59 | ## Second pasta server with environment variables 60 | echo "Testing environment variables ... " 61 | PASTA_BASEURL=pastas PASTA_BINDADDR=127.0.0.1:8201 PASTA_CHARACTERS=12 ../pastad -m ../mime.types & 62 | SECONDPID=$! 63 | sleep 2 # TODO: Don't do sleep here you lazy ... :-) 64 | link2=`../pasta -r http://127.0.0.1:8201 < testfile` 65 | curl --fail -o testfile_second $link 66 | diff testfile testfile_second 67 | kill $SECONDPID 68 | 69 | ## Test spam protection 70 | echo "Testing spam protection ... " 71 | ../pasta -r http://127.0.0.1:8200 testfile >/dev/null 72 | ! timeout 1 ../pasta -r http://127.0.0.1:8200 testfile >/dev/null 73 | sleep 2 74 | ../pasta -r http://127.0.0.1:8200 testfile >/dev/null 75 | 76 | ## TODO: Test expire pasta cleanup 77 | 78 | ## Test special commands 79 | function test_special_command() { 80 | command="$1" 81 | echo "test" > $command 82 | # Ambiguous, if the shortcut command and a similar file exists. This must fail 83 | ! ../pasta -r http://127.0.0.1:8200 "$command" 84 | # However it must pass, if the file is explicitly stated 85 | ../pasta -r http://127.0.0.1:8200 -f "$command" 86 | # And it must succeed, if there is no such file and thus is it clear what should happen 87 | if [[ "$command" != "rm" ]]; then rm "$command"; fi 88 | ../pasta -r http://127.0.0.1:8200 "$command" 89 | } 90 | test_special_command "ls" 91 | test_special_command "rm" 92 | test_special_command "gc" 93 | 94 | ## Test creation of default config 95 | rm -f test_config.toml 96 | ../pastad -c test_config.toml -B http://127.0.0.1:8201 -b 127.0.0.1:8201 & 97 | sleep 2 # TODO: Don't sleep here either but create a valid monitor 98 | kill %2 99 | stat test_config.toml 100 | # Ensure the test config contains the expected entries 101 | grep 'BaseURL[[:space:]]=' test_config.toml 102 | grep 'BindAddress[[:space:]]*=' test_config.toml 103 | grep 'PastaDir[[:space:]]*=' test_config.toml 104 | grep 'MaxPastaSize[[:space:]]*=' test_config.toml 105 | grep 'PastaCharacters[[:space:]]*=' test_config.toml 106 | grep 'Expire[[:space:]]*=' test_config.toml 107 | grep 'Cleanup[[:space:]]*=' test_config.toml 108 | grep 'RequestDelay[[:space:]]*=' test_config.toml 109 | echo "test_config.toml has been successfully created" 110 | 111 | ## Check the date handling of the pasta client 112 | ## Ensure there are no 1970 entries in ls 113 | ! ../pasta ls | grep '1970' 114 | ../pasta ls | grep `date +"%Y-%m-%d"` 115 | 116 | echo "All good :-)" 117 | --------------------------------------------------------------------------------